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
129129 cache-efficiency-debug = ["gix-features/cache-efficiency-debug"]
130130
131131 ## A way to enable most `gitoxide-core` tools found in `ein tools`, namely `organize` and `estimate hours`.
132−gitoxide-core-tools = ["gitoxide-core/organize", "gitoxide-core/estimate-hours", "gitoxide-core-tools-archive"]
132+gitoxide-core-tools = ["gitoxide-core/organize", "gitoxide-core/estimate-hours", "gitoxide-core-tools-archive", "gitoxide-core-tools-clean"]
133133
134134 ## A program to perform analytics on a `git` repository, using an auto-maintained sqlite database
135135 gitoxide-core-tools-query = ["gitoxide-core/query"]
@@ -140,6 +140,9 @@ gitoxide-core-tools-corpus = ["gitoxide-core/corpus"] · 4 unmodified lines
140140 ## A sub-command to generate archive from virtual worktree checkouts.
141141 gitoxide-core-tools-archive = ["gitoxide-core/archive"]
142142
143+## A sub-command to clean the worktree from untracked and ignored files.
144+gitoxide-core-tools-clean = ["gitoxide-core/clean"]
145+
143146 #! ### Building Blocks for mutually exclusive networking
144147 #! Blocking and async features are mutually exclusive and cause a compile-time error. This also means that `cargo … --all-features` will fail.
145148 #! 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
2828 ## The ability to create archives from virtual worktrees, similar to `git archive`.
2929 archive = ["dep:gix-archive-for-configuration-only", "gix/worktree-archive"]
3030
31+## The ability to clean a repository, similar to `git clean`.
32+clean = [ "gix/dirwalk" ]
33+
3134 #! ### Mutually Exclusive Networking
3235 #! If both are set, _blocking-client_ will take precedence, allowing `--all-features` to be used.
3336
gitoxide-core/src/repository/clean.rs+294−0
@@ -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+}
gitoxide-core/src/repository/mod.rs+4−0
@@ -24,6 +24,10 @@ pub mod config; · 23 unmodified lines
2424 mod credential;
2525 pub use credential::function as credential;
2626 pub mod attributes;
27+#[cfg(feature = "clean")]
28+pub mod clean;
29+#[cfg(feature = "clean")]
30+pub use clean::function::clean;
2731 #[cfg(feature = "blocking-client")]
2832 pub mod clone;
2933 pub mod exclude;
src/plumbing/main.rs+38−0
@@ -146,6 +146,44 @@ pub fn main() -> Result<()> { · 145 unmodified lines
146146 }
147147
148148 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+ ),
149187 Subcommands::Status(crate::plumbing::options::status::Platform {
150188 statistics,
151189 submodules,
src/plumbing/options/mod.rs+55−0
@@ -81,6 +81,8 @@ pub enum Subcommands { · 80 unmodified lines
8181 /// Subcommands for creating worktree archives
8282 #[cfg(feature = "gitoxide-core-tools-archive")]
8383 Archive(archive::Platform),
84+ #[cfg(feature = "gitoxide-core-tools-clean")]
85+ Clean(clean::Command),
8486 /// Subcommands for interacting with commit-graphs
8587 #[clap(subcommand)]
8688 CommitGraph(commitgraph::Subcommands),
@@ -478,6 +480,59 @@ pub mod mailmap { · 391 unmodified lines
478480 }
479481 }
480482
483+#[cfg(feature = "gitoxide-core-tools-clean")]
484+pub 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+
481536 pub mod odb {
482537 #[derive(Debug, clap::Subcommand)]
483538 pub enum Subcommands {