Skip to content

Commit 85b30d3

Browse files
committed
feat: basic gix clean
1 parent 205f107 commit 85b30d3

File tree

8 files changed

+281
-1
lines changed

8 files changed

+281
-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: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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+
}
11+
pub(crate) mod function {
12+
use crate::repository::clean::Options;
13+
use crate::OutputFormat;
14+
use anyhow::bail;
15+
use gix::bstr::BString;
16+
use gix_dir::entry::{Kind, Status};
17+
use gix_dir::walk::EmissionMode::CollapseDirectory;
18+
use std::borrow::Cow;
19+
use std::io::{stderr, Write};
20+
use std::path::Path;
21+
22+
pub fn clean(
23+
repo: gix::Repository,
24+
out: &mut dyn std::io::Write,
25+
patterns: Vec<BString>,
26+
Options {
27+
debug,
28+
format,
29+
execute,
30+
ignored,
31+
precious,
32+
directories,
33+
}: Options,
34+
) -> anyhow::Result<()> {
35+
if format != OutputFormat::Human {
36+
bail!("JSON output isn't implemented yet");
37+
}
38+
let Some(workdir) = repo.work_dir() else {
39+
bail!("Need a worktree to clean, this is a bare repository");
40+
};
41+
42+
let index = repo.index()?;
43+
let mut excludes = repo
44+
.excludes(
45+
&index,
46+
None,
47+
gix::worktree::stack::state::ignore::Source::WorktreeThenIdMappingIfNotSkipped,
48+
)?
49+
.detach();
50+
let (mut pathspec, mut maybe_attributes) = repo
51+
.pathspec(
52+
patterns.iter(),
53+
repo.work_dir().is_some(),
54+
&index,
55+
gix::worktree::stack::state::attributes::Source::WorktreeThenIdMapping,
56+
)?
57+
.into_parts();
58+
59+
let prefix = repo.prefix()?.unwrap_or(Path::new(""));
60+
let git_dir_realpath =
61+
gix::path::realpath_opts(repo.git_dir(), repo.current_dir(), gix::path::realpath::MAX_SYMLINKS)?;
62+
let mut collect = gix_dir::walk::delegate::Collect::default();
63+
let fs_caps = repo.filesystem_options()?;
64+
let emission_mode = CollapseDirectory;
65+
let accelerate_lookup = fs_caps.ignore_case.then(|| index.prepare_icase_backing());
66+
gix_dir::walk(
67+
&workdir.join(prefix),
68+
workdir,
69+
gix_dir::walk::Context {
70+
git_dir_realpath: git_dir_realpath.as_ref(),
71+
current_dir: repo.current_dir(),
72+
index: &index,
73+
ignore_case_index_lookup: accelerate_lookup.as_ref(),
74+
pathspec: &mut pathspec,
75+
pathspec_attributes: &mut |relative_path, case, is_dir, out| {
76+
let stack = maybe_attributes
77+
.as_mut()
78+
.expect("can only be called if attributes are used in patterns");
79+
stack
80+
.set_case(case)
81+
.at_entry(relative_path, Some(is_dir), &repo.objects)
82+
.map_or(false, |platform| platform.matching_attributes(out))
83+
},
84+
excludes: Some(&mut excludes),
85+
objects: &repo.objects,
86+
},
87+
gix_dir::walk::Options {
88+
precompose_unicode: fs_caps.precompose_unicode,
89+
ignore_case: fs_caps.ignore_case,
90+
recurse_repositories: false,
91+
emit_pruned: false,
92+
emit_ignored: (ignored || precious).then_some(emission_mode),
93+
for_deletion: true,
94+
emit_tracked: false,
95+
emit_untracked: emission_mode,
96+
emit_empty_directories: directories,
97+
},
98+
&mut collect,
99+
)?;
100+
101+
let entries = collect.into_entries_by_path();
102+
for entry in entries
103+
.into_iter()
104+
.filter_map(|(entry, dir_status)| {
105+
let keep = dir_status.is_none();
106+
if debug && !keep {
107+
writeln!(
108+
stderr(),
109+
"DBG: prune '{}' {:?} as parent dir is used instead",
110+
entry.rela_path,
111+
entry.status
112+
)
113+
.ok();
114+
}
115+
keep.then_some(entry)
116+
})
117+
.filter(|entry| match entry.disk_kind {
118+
Kind::File | Kind::Symlink => true,
119+
Kind::EmptyDirectory | Kind::Directory | Kind::Repository => {
120+
let keep = directories
121+
|| entry
122+
.pathspec_match
123+
.map_or(false, |m| m != gix_dir::entry::PathspecMatch::Always);
124+
if debug && !keep {
125+
writeln!(stderr(), "DBG: prune '{}' as -d is missing", entry.rela_path).ok();
126+
}
127+
keep
128+
}
129+
})
130+
.filter(|e| {
131+
e.pathspec_match.map_or(true, |m| {
132+
let keep = m != gix_dir::entry::PathspecMatch::Excluded;
133+
if debug && !keep {
134+
writeln!(stderr(), "DBG: prune '{}' as it is excluded by pathspec", e.rela_path).ok();
135+
}
136+
keep
137+
})
138+
})
139+
.filter(|e| {
140+
let keep = match e.status {
141+
Status::DotGit | Status::Pruned | Status::TrackedExcluded => {
142+
unreachable!("Pruned aren't emitted")
143+
}
144+
Status::Tracked => {
145+
unreachable!("tracked aren't emitted")
146+
}
147+
Status::Ignored(gix::ignore::Kind::Expendable) => ignored,
148+
Status::Ignored(gix::ignore::Kind::Precious) => precious,
149+
Status::Untracked => true,
150+
};
151+
if debug && !keep {
152+
writeln!(stderr(), "DBG: prune '{}' as -x or -p is missing", e.rela_path).ok();
153+
}
154+
keep
155+
})
156+
{
157+
if entry.disk_kind == gix_dir::entry::Kind::Repository {
158+
writeln!(
159+
out,
160+
"{maybe} skip repository {}",
161+
entry.rela_path,
162+
maybe = if execute { "Will" } else { "Would" }
163+
)?;
164+
continue;
165+
}
166+
writeln!(
167+
out,
168+
"{maybe} {}{} {status}",
169+
entry.rela_path,
170+
entry.disk_kind.is_dir().then_some("/").unwrap_or_default(),
171+
status = match entry.status {
172+
Status::Ignored(kind) => {
173+
Cow::Owned(format!(
174+
"({})",
175+
match kind {
176+
gix::ignore::Kind::Precious => "$",
177+
gix::ignore::Kind::Expendable => "❌",
178+
}
179+
))
180+
}
181+
Status::Untracked => {
182+
"".into()
183+
}
184+
status =>
185+
if debug {
186+
format!("(DBG: {status:?})").into()
187+
} else {
188+
"".into()
189+
},
190+
},
191+
maybe = if execute { "removing" } else { "WOULD remove" },
192+
)?;
193+
}
194+
if !execute {
195+
writeln!(
196+
out,
197+
"\nWARNING: would removes repositories that are hidden inside of ignored directories"
198+
)?;
199+
}
200+
Ok(())
201+
}
202+
}

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

src/plumbing/options/mod.rs

Lines changed: 29 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,33 @@ 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+
/// The git path specifications to list attributes for, or unset to read from stdin one per line.
505+
#[clap(value_parser = CheckPathSpec)]
506+
pub pathspec: Vec<BString>,
507+
}
508+
}
509+
481510
pub mod odb {
482511
#[derive(Debug, clap::Subcommand)]
483512
pub enum Subcommands {

0 commit comments

Comments
 (0)