Skip to content

Commit e8597f3

Browse files
committed
feat: basic gix clean
1 parent ae86a6a commit e8597f3

File tree

6 files changed

+398
-1
lines changed

6 files changed

+398
-1
lines changed

Diff for: Cargo.toml

+4-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ prodash-render-line = ["prodash/render-line", "prodash-render-line-crossterm", "
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"]
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.

Diff for: gitoxide-core/Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ corpus = [ "dep:rusqlite", "dep:sysinfo", "organize", "dep:crossbeam-channel", "
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

Diff for: gitoxide-core/src/repository/clean.rs

+294
Original file line numberDiff line numberDiff line change
@@ -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+
}

Diff for: gitoxide-core/src/repository/mod.rs

+4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ pub mod config;
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;

Diff for: src/plumbing/main.rs

+38
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,44 @@ pub fn main() -> Result<()> {
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,

0 commit comments

Comments
 (0)