Skip to content

Commit f3aea6a

Browse files
committed
feat: basic gix clean
1 parent 35f97e5 commit f3aea6a

File tree

8 files changed

+349
-1
lines changed

8 files changed

+349
-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.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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: 4 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 = [ "dep:gix-dir" ]
33+
3134
#! ### Mutually Exclusive Networking
3235
#! If both are set, _blocking-client_ will take precedence, allowing `--all-features` to be used.
3336

@@ -49,6 +52,7 @@ gix-pack-for-configuration-only = { package = "gix-pack", version = "^0.48.0", p
4952
gix-transport-configuration-only = { package = "gix-transport", version = "^0.41.0", path = "../gix-transport", default-features = false }
5053
gix-archive-for-configuration-only = { package = "gix-archive", version = "^0.9.0", path = "../gix-archive", optional = true, features = ["tar", "tar_gz"] }
5154
gix-status = { version = "^0.6.0", path = "../gix-status" }
55+
gix-dir = { version = "^0.1.0", path = "../gix-dir", optional = true }
5256
gix-fsck = { version = "^0.3.0", path = "../gix-fsck" }
5357
serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] }
5458
anyhow = "1.0.42"

gitoxide-core/src/repository/clean.rs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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+
use std::path::Path;
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_nonbare_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 mut excludes = repo
47+
.excludes(
48+
&index,
49+
None,
50+
gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
51+
)?
52+
.detach();
53+
let (mut pathspec, mut maybe_attributes) = repo
54+
.pathspec(
55+
patterns.iter(),
56+
repo.work_dir().is_some(),
57+
&index,
58+
gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping,
59+
)?
60+
.into_parts();
61+
62+
let prefix = repo.prefix()?.unwrap_or(Path::new(""));
63+
let git_dir_realpath =
64+
gix::path::realpath_opts(repo.git_dir(), repo.current_dir(), gix::path::realpath::MAX_SYMLINKS)?;
65+
let mut collect = gix_dir::walk::delegate::Collect::default();
66+
let fs_caps = repo.filesystem_options()?;
67+
let emission_mode = CollapseDirectory;
68+
let accelerate_lookup = fs_caps.ignore_case.then(|| index.prepare_icase_backing());
69+
gix_dir::walk(
70+
&workdir.join(prefix),
71+
workdir,
72+
gix_dir::walk::Context {
73+
git_dir_realpath: git_dir_realpath.as_ref(),
74+
current_dir: repo.current_dir(),
75+
index: &index,
76+
ignore_case_index_lookup: accelerate_lookup.as_ref(),
77+
pathspec: &mut pathspec,
78+
pathspec_attributes: &mut |relative_path, case, is_dir, out| {
79+
let stack = maybe_attributes
80+
.as_mut()
81+
.expect("can only be called if attributes are used in patterns");
82+
stack
83+
.set_case(case)
84+
.at_entry(relative_path, Some(is_dir), &repo.objects)
85+
.map_or(false, |platform| platform.matching_attributes(out))
86+
},
87+
excludes: Some(&mut excludes),
88+
objects: &repo.objects,
89+
},
90+
gix_dir::walk::Options {
91+
precompose_unicode: fs_caps.precompose_unicode,
92+
ignore_case: fs_caps.ignore_case,
93+
recurse_repositories: false,
94+
emit_pruned: true,
95+
emit_ignored: (ignored || precious).then_some(emission_mode),
96+
for_deletion: Some(if skip_hidden_nonbare_repositories {
97+
FindRepositoriesInIgnoredDirectories
98+
} else {
99+
IgnoredDirectoriesCanHideNestedRepositories
100+
}),
101+
emit_tracked: false,
102+
emit_untracked: emission_mode,
103+
emit_empty_directories: true,
104+
},
105+
&mut collect,
106+
)?;
107+
108+
let entries = collect.into_entries_by_path();
109+
let mut entries_to_clean = 0;
110+
let mut skipped_directories = 0;
111+
let mut pruned_entries = 0;
112+
for (entry, dir_status) in entries.into_iter() {
113+
if dir_status.is_some() {
114+
if debug {
115+
writeln!(
116+
err,
117+
"DBG: prune '{}' {:?} as parent dir is used instead",
118+
entry.rela_path, entry.status
119+
)
120+
.ok();
121+
}
122+
continue;
123+
}
124+
125+
pruned_entries += usize::from(entry.pathspec_match.is_none());
126+
if entry.status.is_pruned() || entry.pathspec_match.is_none() {
127+
continue;
128+
}
129+
let disk_kind = entry.disk_kind.expect("present if not pruned");
130+
match disk_kind {
131+
Kind::File | Kind::Symlink => {}
132+
Kind::EmptyDirectory | Kind::Directory | Kind::Repository => {
133+
let keep = directories
134+
|| entry
135+
.pathspec_match
136+
.map_or(false, |m| m != gix_dir::entry::PathspecMatch::Always);
137+
if !keep {
138+
skipped_directories += 1;
139+
if debug {
140+
writeln!(err, "DBG: prune '{}' as -d is missing", entry.rela_path).ok();
141+
}
142+
continue;
143+
}
144+
}
145+
};
146+
147+
let keep = entry
148+
.pathspec_match
149+
.map_or(true, |m| m != gix_dir::entry::PathspecMatch::Excluded);
150+
if !keep {
151+
if debug {
152+
writeln!(err, "DBG: prune '{}' as it is excluded by pathspec", entry.rela_path).ok();
153+
}
154+
continue;
155+
}
156+
157+
let keep = match entry.status {
158+
Status::DotGit | Status::Pruned | Status::TrackedExcluded => {
159+
unreachable!("Pruned aren't emitted")
160+
}
161+
Status::Tracked => {
162+
unreachable!("tracked aren't emitted")
163+
}
164+
Status::Ignored(gix::ignore::Kind::Expendable) => ignored,
165+
Status::Ignored(gix::ignore::Kind::Precious) => precious,
166+
Status::Untracked => true,
167+
};
168+
if !keep {
169+
if debug {
170+
writeln!(err, "DBG: prune '{}' as -x or -p is missing", entry.rela_path).ok();
171+
}
172+
continue;
173+
}
174+
175+
if disk_kind == gix_dir::entry::Kind::Repository {
176+
writeln!(
177+
out,
178+
"{maybe} skip repository {}",
179+
entry.rela_path,
180+
maybe = if execute { "Will" } else { "Would" }
181+
)?;
182+
continue;
183+
}
184+
writeln!(
185+
out,
186+
"{maybe} {}{} {status}",
187+
entry.rela_path,
188+
disk_kind.is_dir().then_some("/").unwrap_or_default(),
189+
status = match entry.status {
190+
Status::Ignored(kind) => {
191+
Cow::Owned(format!(
192+
"({})",
193+
match kind {
194+
gix::ignore::Kind::Precious => "$",
195+
gix::ignore::Kind::Expendable => "❌",
196+
}
197+
))
198+
}
199+
Status::Untracked => {
200+
"".into()
201+
}
202+
status =>
203+
if debug {
204+
format!("(DBG: {status:?})").into()
205+
} else {
206+
"".into()
207+
},
208+
},
209+
maybe = if execute { "removing" } else { "WOULD remove" },
210+
)?;
211+
212+
if execute {
213+
let path = workdir.join(gix::path::from_bstr(entry.rela_path));
214+
if disk_kind.is_dir() {
215+
std::fs::remove_dir_all(path)?;
216+
} else {
217+
std::fs::remove_file(path)?;
218+
}
219+
} else {
220+
entries_to_clean += 1;
221+
}
222+
}
223+
if !execute {
224+
let mut messages = Vec::new();
225+
messages.extend(
226+
(skipped_directories > 0)
227+
.then(|| format!("Ignored {skipped_directories} directories - use -d to show")),
228+
);
229+
messages.extend(
230+
(pruned_entries > 0 && !patterns.is_empty()).then(|| {
231+
format!("try to adjust your pathspec to reveal some of the {pruned_entries} pruned entries")
232+
}),
233+
);
234+
let make_msg = || -> String {
235+
if messages.is_empty() {
236+
return String::new();
237+
}
238+
format!(" ({})", messages.join("; "))
239+
};
240+
if entries_to_clean > 0 {
241+
if skip_hidden_nonbare_repositories {
242+
writeln!(
243+
out,
244+
"\nWARNING: would STILL remove all BARE repositories that are hidden inside of ignored directories{}", make_msg()
245+
)?;
246+
} else {
247+
writeln!(
248+
out,
249+
"\nWARNING: would remove all repositories that are hidden inside of ignored directories{}",
250+
make_msg()
251+
)?;
252+
}
253+
} else {
254+
let msg = if ignored {
255+
"No untracked or ignored files to clean"
256+
} else {
257+
"No untracked files to clean - use -x or -p to show more"
258+
};
259+
writeln!(out, "{msg}{}", make_msg())?;
260+
}
261+
}
262+
Ok(())
263+
}
264+
}

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,

0 commit comments

Comments
 (0)