Skip to content

Commit 0f5951b

Browse files
committed
feat: basic gix clean
1 parent 1982ea2 commit 0f5951b

File tree

7 files changed

+303
-1
lines changed

7 files changed

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

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_nonbare_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_nonbare_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_nonbare_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)