History

feat: basic `gix clean`

Sebastian Thiel committed 2 years ago

Commit e8597f31 parent ae86a6a6 files · +398 −1Browse files

Unified · Split

6 files
  1. Cargo.toml +4 −1
  2. gitoxide-core/Cargo.toml +3 −0
  3. gitoxide-core/src/repository/clean.rs +294 −0
  4. gitoxide-core/src/repository/mod.rs +4 −0
  5. src/plumbing/main.rs +38 −0
  6. src/plumbing/options/mod.rs +55 −0
Cargo.toml+4−1
@@ -129,7 +129,7 @@ prodash-render-line = ["prodash/render-line", "prodash-render-line-crossterm", " · 128 unmodified lines
129cache-efficiency-debug = ["gix-features/cache-efficiency-debug"]129cache-efficiency-debug = ["gix-features/cache-efficiency-debug"]
130 130
131## A way to enable most `gitoxide-core` tools found in `ein tools`, namely `organize` and `estimate hours`.131## A way to enable most `gitoxide-core` tools found in `ein tools`, namely `organize` and `estimate hours`.
132gitoxide-core-tools = ["gitoxide-core/organize", "gitoxide-core/estimate-hours", "gitoxide-core-tools-archive"]132gitoxide-core-tools = ["gitoxide-core/organize", "gitoxide-core/estimate-hours", "gitoxide-core-tools-archive", "gitoxide-core-tools-clean"]
133 133
134## A program to perform analytics on a `git` repository, using an auto-maintained sqlite database134## A program to perform analytics on a `git` repository, using an auto-maintained sqlite database
135gitoxide-core-tools-query = ["gitoxide-core/query"]135gitoxide-core-tools-query = ["gitoxide-core/query"]
@@ -140,6 +140,9 @@ gitoxide-core-tools-corpus = ["gitoxide-core/corpus"] · 4 unmodified lines
140## A sub-command to generate archive from virtual worktree checkouts.140## A sub-command to generate archive from virtual worktree checkouts.
141gitoxide-core-tools-archive = ["gitoxide-core/archive"]141gitoxide-core-tools-archive = ["gitoxide-core/archive"]
142 142
143## A sub-command to clean the worktree from untracked and ignored files.
144gitoxide-core-tools-clean = ["gitoxide-core/clean"]
145
143#! ### Building Blocks for mutually exclusive networking146#! ### Building Blocks for mutually exclusive networking
144#! Blocking and async features are mutually exclusive and cause a compile-time error. This also means that `cargo … --all-features` will fail.147#! Blocking and async features are mutually exclusive and cause a compile-time error. This also means that `cargo … --all-features` will fail.
145#! Within each section, features can be combined.148#! Within each section, features can be combined.
gitoxide-core/Cargo.toml+3−0
@@ -28,6 +28,9 @@ corpus = [ "dep:rusqlite", "dep:sysinfo", "organize", "dep:crossbeam-channel", " · 27 unmodified lines
28## The ability to create archives from virtual worktrees, similar to `git archive`.28## The ability to create archives from virtual worktrees, similar to `git archive`.
29archive = ["dep:gix-archive-for-configuration-only", "gix/worktree-archive"]29archive = ["dep:gix-archive-for-configuration-only", "gix/worktree-archive"]
30 30
31## The ability to clean a repository, similar to `git clean`.
32clean = [ "gix/dirwalk" ]
33
31#! ### Mutually Exclusive Networking34#! ### Mutually Exclusive Networking
32#! If both are set, _blocking-client_ will take precedence, allowing `--all-features` to be used.35#! If both are set, _blocking-client_ will take precedence, allowing `--all-features` to be used.
33 36
gitoxide-core/src/repository/clean.rs+294−0
@@ -0,0 +1,294 @@
1use crate::OutputFormat;
2
3#[derive(Default, Copy, Clone)]
4pub enum FindRepository {
5 #[default]
6 NonBare,
7 All,
8}
9
10pub 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}
21pub(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}
gitoxide-core/src/repository/mod.rs+4−0
@@ -24,6 +24,10 @@ pub mod config; · 23 unmodified lines
24mod credential;24mod credential;
25pub use credential::function as credential;25pub use credential::function as credential;
26pub mod attributes;26pub mod attributes;
27#[cfg(feature = "clean")]
28pub mod clean;
29#[cfg(feature = "clean")]
30pub use clean::function::clean;
27#[cfg(feature = "blocking-client")]31#[cfg(feature = "blocking-client")]
28pub mod clone;32pub mod clone;
29pub mod exclude;33pub mod exclude;
src/plumbing/main.rs+38−0
@@ -146,6 +146,44 @@ pub fn main() -> Result<()> { · 145 unmodified lines
146 }146 }
147 147
148 match cmd {148 match cmd {
149 #[cfg(feature = "gitoxide-core-tools-clean")]
150 Subcommands::Clean(crate::plumbing::options::clean::Command {
151 debug,
152 execute,
153 ignored,
154 precious,
155 directories,
156 pathspec,
157 repositories,
158 skip_hidden_repositories,
159 find_untracked_repositories,
160 }) => prepare_and_run(
161 "clean",
162 trace,
163 verbose,
164 progress,
165 progress_keep_open,
166 None,
167 move |_progress, out, err| {
168 core::repository::clean(
169 repository(Mode::Lenient)?,
170 out,
171 err,
172 pathspec,
173 core::repository::clean::Options {
174 debug,
175 format,
176 execute,
177 ignored,
178 precious,
179 directories,
180 repositories,
181 skip_hidden_repositories: skip_hidden_repositories.map(Into::into),
182 find_untracked_repositories: find_untracked_repositories.into(),
183 },
184 )
185 },
186 ),
149 Subcommands::Status(crate::plumbing::options::status::Platform {187 Subcommands::Status(crate::plumbing::options::status::Platform {
150 statistics,188 statistics,
151 submodules,189 submodules,
src/plumbing/options/mod.rs+55−0
@@ -81,6 +81,8 @@ pub enum Subcommands { · 80 unmodified lines
81 /// Subcommands for creating worktree archives81 /// Subcommands for creating worktree archives
82 #[cfg(feature = "gitoxide-core-tools-archive")]82 #[cfg(feature = "gitoxide-core-tools-archive")]
83 Archive(archive::Platform),83 Archive(archive::Platform),
84 #[cfg(feature = "gitoxide-core-tools-clean")]
85 Clean(clean::Command),
84 /// Subcommands for interacting with commit-graphs86 /// Subcommands for interacting with commit-graphs
85 #[clap(subcommand)]87 #[clap(subcommand)]
86 CommitGraph(commitgraph::Subcommands),88 CommitGraph(commitgraph::Subcommands),
@@ -478,6 +480,59 @@ pub mod mailmap { · 391 unmodified lines
478 }480 }
479}481}
480 482
483#[cfg(feature = "gitoxide-core-tools-clean")]
484pub mod clean {
485 use gitoxide::shared::CheckPathSpec;
486 use gix::bstr::BString;
487
488 #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
489 pub enum FindRepository {
490 All,
491 #[default]
492 NonBare,
493 }
494
495 impl From<FindRepository> for gitoxide_core::repository::clean::FindRepository {
496 fn from(value: FindRepository) -> Self {
497 match value {
498 FindRepository::All => gitoxide_core::repository::clean::FindRepository::All,
499 FindRepository::NonBare => gitoxide_core::repository::clean::FindRepository::NonBare,
500 }
501 }
502 }
503
504 #[derive(Debug, clap::Parser)]
505 pub struct Command {
506 /// Print additional debug information to help understand decisions it made.
507 #[arg(long)]
508 pub debug: bool,
509 /// Actually perform the operation, which deletes files on disk without chance of recovery.
510 #[arg(long, short = 'e')]
511 pub execute: bool,
512 /// Remove ignored (and expendable) files.
513 #[arg(long, short = 'x')]
514 pub ignored: bool,
515 /// Remove precious files.
516 #[arg(long, short = 'p')]
517 pub precious: bool,
518 /// Remove whole directories.
519 #[arg(long, short = 'd')]
520 pub directories: bool,
521 /// Remove nested repositories.
522 #[arg(long, short = 'r')]
523 pub repositories: bool,
524 /// Enter ignored directories to skip repositories contained within.
525 #[arg(long)]
526 pub skip_hidden_repositories: Option<FindRepository>,
527 /// What kind of repositories to find inside of untracked directories.
528 #[arg(long, default_value = "non-bare")]
529 pub find_untracked_repositories: FindRepository,
530 /// The git path specifications to list attributes for, or unset to read from stdin one per line.
531 #[clap(value_parser = CheckPathSpec)]
532 pub pathspec: Vec<BString>,
533 }
534}
535
481pub mod odb {536pub mod odb {
482 #[derive(Debug, clap::Subcommand)]537 #[derive(Debug, clap::Subcommand)]
483 pub enum Subcommands {538 pub enum Subcommands {