Skip to content

Commit e93b2fe

Browse files
committed
feat: basic gix clean
1 parent a6cbf6f commit e93b2fe

File tree

7 files changed

+315
-1
lines changed

7 files changed

+315
-1
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@ target/
66

77
# repositories used for local testing
88
/tests/fixtures/repos
9+
$/tests/fixtures/repos/
10+
911
/tests/fixtures/commit-graphs/
12+
$/tests/fixtures/commit-graphs/
1013

1114
**/generated-do-not-edit/
1215

1316
# Cargo lock files of fuzz targets - let's have the latest versions of everything under test
1417
**/fuzz/Cargo.lock
18+
19+
# newer Git sees these as precious, older Git falls through to the pattern above
20+
$**/fuzz/Cargo.lock

Cargo.toml

Lines changed: 4 additions & 1 deletion
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.

gitoxide-core/Cargo.toml

Lines changed: 3 additions & 0 deletions
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

gitoxide-core/src/repository/clean.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
use crate::OutputFormat;
2+
3+
pub struct Options {
4+
pub debug: bool,
5+
pub format: OutputFormat,
6+
pub execute: bool,
7+
pub ignored: bool,
8+
pub precious: bool,
9+
pub directories: bool,
10+
pub skip_hidden_repositories: bool,
11+
}
12+
pub(crate) mod function {
13+
use crate::repository::clean::Options;
14+
use crate::OutputFormat;
15+
use anyhow::bail;
16+
use gix::bstr::BString;
17+
use gix::bstr::ByteSlice;
18+
use gix::dir::entry::{Kind, Status};
19+
use gix::dir::walk::EmissionMode::CollapseDirectory;
20+
use gix::dir::walk::ForDeletionMode::*;
21+
use std::borrow::Cow;
22+
23+
pub fn clean(
24+
repo: gix::Repository,
25+
out: &mut dyn std::io::Write,
26+
err: &mut dyn std::io::Write,
27+
patterns: Vec<BString>,
28+
Options {
29+
debug,
30+
format,
31+
execute,
32+
ignored,
33+
precious,
34+
directories,
35+
skip_hidden_repositories,
36+
}: Options,
37+
) -> anyhow::Result<()> {
38+
if format != OutputFormat::Human {
39+
bail!("JSON output isn't implemented yet");
40+
}
41+
let Some(workdir) = repo.work_dir() else {
42+
bail!("Need a worktree to clean, this is a bare repository");
43+
};
44+
45+
let index = repo.index()?;
46+
let has_patterns = !patterns.is_empty();
47+
let mut collect = gix::dir::walk::delegate::Collect::default();
48+
let emission_mode = CollapseDirectory;
49+
let options = repo
50+
.dirwalk_options()?
51+
.emit_pruned(true)
52+
.emit_ignored((ignored || precious).then_some(emission_mode))
53+
.for_deletion(
54+
if (ignored || precious) && directories && skip_hidden_repositories {
55+
FindRepositoriesInIgnoredDirectories
56+
} else {
57+
IgnoredDirectoriesCanHideNestedRepositories
58+
}
59+
.into(),
60+
)
61+
.emit_untracked(emission_mode)
62+
.emit_empty_directories(true);
63+
repo.dirwalk(&index, patterns, options, &mut collect)?;
64+
let prefix = repo.prefix()?.expect("worktree and valid current dir");
65+
let prefix_len = if prefix.as_os_str().is_empty() {
66+
0
67+
} else {
68+
prefix.to_str().map_or(0, |s| s.len() + 1 /* slash */)
69+
};
70+
71+
let entries = collect.into_entries_by_path();
72+
let mut entries_to_clean = 0;
73+
let mut skipped_directories = 0;
74+
let mut pruned_entries = 0;
75+
for (entry, dir_status) in entries.into_iter() {
76+
if dir_status.is_some() {
77+
if debug {
78+
writeln!(
79+
err,
80+
"DBG: prune '{}' {:?} as parent dir is used instead",
81+
entry.rela_path, entry.status
82+
)
83+
.ok();
84+
}
85+
continue;
86+
}
87+
88+
pruned_entries += usize::from(entry.pathspec_match.is_none());
89+
if entry.status.is_pruned() || entry.pathspec_match.is_none() {
90+
continue;
91+
}
92+
let mut disk_kind = entry.disk_kind.expect("present if not pruned");
93+
match disk_kind {
94+
Kind::File | Kind::Symlink => {}
95+
Kind::EmptyDirectory | Kind::Directory | Kind::Repository => {
96+
let keep = directories
97+
|| entry
98+
.pathspec_match
99+
.map_or(false, |m| m != gix::dir::entry::PathspecMatch::Always);
100+
if !keep {
101+
skipped_directories += 1;
102+
if debug {
103+
writeln!(err, "DBG: prune '{}' as -d is missing", entry.rela_path).ok();
104+
}
105+
continue;
106+
}
107+
}
108+
};
109+
110+
let keep = entry
111+
.pathspec_match
112+
.map_or(true, |m| m != gix::dir::entry::PathspecMatch::Excluded);
113+
if !keep {
114+
if debug {
115+
writeln!(err, "DBG: prune '{}' as it is excluded by pathspec", entry.rela_path).ok();
116+
}
117+
continue;
118+
}
119+
120+
let keep = match entry.status {
121+
Status::DotGit | Status::Pruned | Status::TrackedExcluded => {
122+
unreachable!("Pruned aren't emitted")
123+
}
124+
Status::Tracked => {
125+
unreachable!("tracked aren't emitted")
126+
}
127+
Status::Ignored(gix::ignore::Kind::Expendable) => ignored,
128+
Status::Ignored(gix::ignore::Kind::Precious) => precious,
129+
Status::Untracked => true,
130+
};
131+
if !keep {
132+
if debug {
133+
writeln!(err, "DBG: prune '{}' as -x or -p is missing", entry.rela_path).ok();
134+
}
135+
continue;
136+
}
137+
138+
if disk_kind == gix::dir::entry::Kind::Directory {
139+
if gix::discover::is_git(&workdir.join(gix::path::from_bstr(entry.rela_path.as_bstr()))).is_ok() {
140+
if debug {
141+
writeln!(err, "DBG: upgraded directory '{}' to repository", entry.rela_path).ok();
142+
}
143+
disk_kind = gix::dir::entry::Kind::Repository;
144+
}
145+
}
146+
147+
let display_path = entry.rela_path[prefix_len..].as_bstr();
148+
if disk_kind == gix::dir::entry::Kind::Repository {
149+
writeln!(
150+
err,
151+
"{maybe} skip repository {}",
152+
display_path,
153+
maybe = if execute { "Will" } else { "Would" }
154+
)?;
155+
continue;
156+
}
157+
writeln!(
158+
out,
159+
"{maybe} {}{} {status}",
160+
display_path,
161+
disk_kind.is_dir().then_some("/").unwrap_or_default(),
162+
status = match entry.status {
163+
Status::Ignored(kind) => {
164+
Cow::Owned(format!(
165+
"({})",
166+
match kind {
167+
gix::ignore::Kind::Precious => "$",
168+
gix::ignore::Kind::Expendable => "❌",
169+
}
170+
))
171+
}
172+
Status::Untracked => {
173+
"".into()
174+
}
175+
status =>
176+
if debug {
177+
format!("(DBG: {status:?})").into()
178+
} else {
179+
"".into()
180+
},
181+
},
182+
maybe = if execute { "removing" } else { "WOULD remove" },
183+
)?;
184+
185+
if execute {
186+
let path = workdir.join(gix::path::from_bstr(entry.rela_path));
187+
if disk_kind.is_dir() {
188+
std::fs::remove_dir_all(path)?;
189+
} else {
190+
std::fs::remove_file(path)?;
191+
}
192+
} else {
193+
entries_to_clean += 1;
194+
}
195+
}
196+
if !execute {
197+
let mut messages = Vec::new();
198+
messages.extend(
199+
(skipped_directories > 0)
200+
.then(|| format!("Ignored {skipped_directories} directories - use -d to show")),
201+
);
202+
messages.extend(
203+
(pruned_entries > 0 && has_patterns).then(|| {
204+
format!("try to adjust your pathspec to reveal some of the {pruned_entries} pruned entries")
205+
}),
206+
);
207+
let make_msg = || -> String {
208+
if messages.is_empty() {
209+
return String::new();
210+
}
211+
format!(" ({})", messages.join("; "))
212+
};
213+
if entries_to_clean > 0 {
214+
if !skip_hidden_repositories {
215+
writeln!(
216+
err,
217+
"\nWARNING: would remove all repositories that are hidden inside of ignored directories{}",
218+
make_msg()
219+
)?;
220+
}
221+
} else {
222+
let msg = if ignored {
223+
"No untracked or ignored files to clean"
224+
} else {
225+
"No untracked files to clean - use -x or -p to show more"
226+
};
227+
writeln!(err, "{msg}{}", make_msg())?;
228+
}
229+
}
230+
Ok(())
231+
}
232+
}

gitoxide-core/src/repository/mod.rs

Lines changed: 4 additions & 0 deletions
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;

src/plumbing/main.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,40 @@ 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+
skip_hidden_repositories,
158+
}) => prepare_and_run(
159+
"clean",
160+
trace,
161+
verbose,
162+
progress,
163+
progress_keep_open,
164+
None,
165+
move |_progress, out, err| {
166+
core::repository::clean(
167+
repository(Mode::Lenient)?,
168+
out,
169+
err,
170+
pathspec,
171+
core::repository::clean::Options {
172+
debug,
173+
format,
174+
execute,
175+
ignored,
176+
precious,
177+
directories,
178+
skip_hidden_repositories,
179+
},
180+
)
181+
},
182+
),
149183
Subcommands::Status(crate::plumbing::options::status::Platform {
150184
statistics,
151185
submodules,

src/plumbing/options/mod.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ pub enum Subcommands {
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,36 @@ pub mod mailmap {
478480
}
479481
}
480482

483+
pub mod clean {
484+
use gitoxide::shared::CheckPathSpec;
485+
use gix::bstr::BString;
486+
487+
#[derive(Debug, clap::Parser)]
488+
pub struct Command {
489+
/// Print additional debug information to help understand decisions it made.
490+
#[arg(long)]
491+
pub debug: bool,
492+
/// Actually perform the operation, which deletes files on disk without chance of recovery.
493+
#[arg(long, short = 'e')]
494+
pub execute: bool,
495+
/// Remove ignored (and expendable) files.
496+
#[arg(long, short = 'x')]
497+
pub ignored: bool,
498+
/// Remove precious files.
499+
#[arg(long, short = 'p')]
500+
pub precious: bool,
501+
/// Remove whole directories.
502+
#[arg(long, short = 'd')]
503+
pub directories: bool,
504+
/// Enter ignored directories to skip repositories contained within.
505+
#[arg(long)]
506+
pub skip_hidden_repositories: bool,
507+
/// The git path specifications to list attributes for, or unset to read from stdin one per line.
508+
#[clap(value_parser = CheckPathSpec)]
509+
pub pathspec: Vec<BString>,
510+
}
511+
}
512+
481513
pub mod odb {
482514
#[derive(Debug, clap::Subcommand)]
483515
pub enum Subcommands {

0 commit comments

Comments
 (0)