diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..616a987
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,49 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+permissions:
+ contents: read
+
+env:
+ CARGO_TERM_COLOR: always
+ RUSTFLAGS: -D warnings
+
+jobs:
+ fmt:
+ name: rustfmt
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt
+ - run: cargo fmt --all --check
+
+ clippy:
+ name: clippy
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo clippy --all-targets --all-features -- -D warnings
+
+ test:
+ name: build & test (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - run: cargo build --locked --verbose
+ - run: cargo test --locked --verbose
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3e6591a
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,65 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v[0-9]+.[0-9]+.[0-9]+*"
+
+permissions:
+ contents: write
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ # Create the GitHub Release for the pushed tag (binaries attach to it below).
+ create-release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: taiki-e/create-gh-release-action@v1
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ # Build a static-ish binary per platform and upload it to the release as an
+ # archive (.tar.gz on unix, .zip on windows) plus a .sha256 checksum.
+ upload-assets:
+ needs: create-release
+ name: ${{ matrix.target }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ - target: x86_64-apple-darwin
+ os: macos-latest
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ - target: x86_64-pc-windows-msvc
+ os: windows-latest
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: taiki-e/upload-rust-binary-action@v1
+ with:
+ bin: cargo-targkill
+ target: ${{ matrix.target }}
+ archive: targkill-$tag-$target
+ tar: unix
+ zip: windows
+ checksum: sha256
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ # Publish the crate to crates.io. Requires the CARGO_REGISTRY_TOKEN secret.
+ publish-crate:
+ needs: upload-assets
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ - run: cargo publish --locked
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
diff --git a/README.md b/README.md
index abef675..c0275ca 100644
--- a/README.md
+++ b/README.md
@@ -1,66 +1,151 @@
-# targkill
+
+
+# π¦ targkill
+
+**Easily find and reclaim the gigabytes that Rust `target/` directories eat up.**
+
+Like [`npkill`](https://github.com/voidcosmos/npkill), but for Cargo. A blazing-fast,
+parallel filesystem scanner wrapped in a friendly terminal UI β list every `target/`
+directory on your machine, see how much space each one is wasting, and wipe the ones
+you don't need.
+
+[](https://github.com/M4cs/targkill/actions/workflows/ci.yml)
+[](https://crates.io/crates/targkill)
+[](https://crates.io/crates/targkill)
+[](./LICENSE)
+
+
+
+---
+
+```text
+β targkill ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β 42 dirs β’ reclaimable 37.8 GiB β’ selected 3 (12.1 GiB) β
+β ββββββββββββββββββββββββββββββββββββββββββ sizing 31/42 β
+ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β [x] 6.4 GiB 3d C:\dev\monorepo\target
+ [x] 3.9 GiB 1w C:\dev\game-engine\target
+ [ ] 2.1 GiB 2mo C:\dev\old-prototype\target
+ [x] 1.8 GiB 5d C:\dev\service\crates\api\target
+ [ ] 412 MiB 1d C:\dev\scratch\target
+ β¦
+ β³ Sizing 42 directoriesβ¦ β freed 8.3 GiB
+ ββ/jk move space select a all n none d/del delete s sort q quit
+```
+
+> π‘ The bar at the top animates while scanning, then fills in as each directory's
+> size is computed. Sizes appear **live** β you don't wait for the whole scan to finish.
+
+## β¨ Features
-An interactive TUI to find and reclaim disk space from Rust `target/` directories β
-like [`npkill`](https://github.com/voidcosmos/npkill), but for Cargo.
+- β‘ **Fast** β parallel, multi-threaded directory walk powered by [`jwalk`](https://crates.io/crates/jwalk); target dirs are pruned mid-walk so their huge contents are never traversed.
+- π― **Accurate** β only real Cargo targets (a `target/` with a sibling `Cargo.toml`) are listed. A random folder named `target` is never touched.
+- π **Live sizing** β every directory is measured the moment it's found, concurrently with the rest of the scan, with a running "reclaimable" total.
+- π₯οΈ **Interactive TUI** β built on [`ratatui`](https://crates.io/crates/ratatui); navigate, multi-select, sort, and delete without leaving the terminal.
+- π§΅ **Tunable** β pick the worker-thread count with `-j/--threads`.
+- π½ **Whole-system or scoped** β scan every drive at once, or point it at a specific folder.
+- π‘οΈ **Safe by default** β deletions ask for a `y/N` confirmation and run in the background so the UI never freezes.
+- π **Scriptable** β `--list` prints results as plain text for piping into other tools.
+- πͺ **Cross-platform** β Linux, macOS, and Windows.
-It walks the filesystem in parallel, detects every real Cargo `target/` dir (one with a
-sibling `Cargo.toml`, so unrelated folders named "target" are never touched), computes
-sizes in the background, and lets you delete them from a live list.
+## π¦ Install
-## Install
+### From crates.io (recommended)
```sh
-# From the GitHub repo:
-cargo install --git https://github.com/M4cs/targkill
+cargo install targkill
+```
-# Or from a local checkout:
+### Prebuilt binaries
+
+Grab a binary for your platform from the [latest release](https://github.com/M4cs/targkill/releases/latest),
+unpack it, and put `cargo-targkill` somewhere on your `PATH`.
+
+### From source
+
+```sh
+cargo install --git https://github.com/M4cs/targkill
+# or, from a local clone:
+git clone https://github.com/M4cs/targkill && cd targkill
cargo install --path .
```
-This installs a `cargo-targkill` binary, which Cargo exposes as the `cargo targkill`
-subcommand. You can run it either way:
+All of these install a `cargo-targkill` binary, which Cargo exposes as a subcommand.
+You can run it either way:
```sh
-cargo targkill # via Cargo
-cargo-targkill # directly (it's on your PATH after install)
+cargo targkill # via Cargo
+cargo-targkill # directly (it's on your PATH)
```
-## Usage
+## π Usage
```sh
-cargo targkill # no path: scan every drive on the system
-cargo targkill [PATH...] # scan one or more roots, recursively
-cargo targkill -j 8 [PATH] # use 8 worker threads (default: one per core)
-cargo targkill --list [PATH] # headless: print dirs + sizes, no TUI (scriptable)
+cargo targkill # no path: scan every drive on the system
+cargo targkill ~/dev # scan a specific folder, recursively
+cargo targkill ~/dev /work # scan multiple roots
+cargo targkill -j 8 ~/dev # use 8 worker threads (default: one per core)
+cargo targkill --list ~/dev # print results and exit (no TUI)
```
-The TUI opens immediately. Found `target/` dirs stream into the list live as the
-walk discovers them, and each dir's size is computed the moment it's found β so
-sizes (and the running "reclaimable" total) fill in *while* the scan is still going,
-not only at the end. A progress bar in the top panel animates during discovery, then
-shows a determinate `sizing X/Y` bar until the last size lands. Discovery and sizing
-run on separate multi-threaded pools (`-j/--threads` to tune the worker count).
+### Options
-With no path, every drive letter on the system is scanned (enumerated without
-touching removable-drive media, so empty card-reader slots don't stall startup).
+| Option | Description |
+|--------|-------------|
+| `[PATH...]` | Roots to scan recursively. Defaults to **every drive** on the system. |
+| `-j`, `--threads ` | Worker threads for scanning & sizing. `0`/omitted = auto (one per core). |
+| `-l`, `--list` | Print discovered dirs and sizes to stdout, then exit. Great for scripts. |
+| `-h`, `--help` | Show help. |
-## Keys
+### Keybindings
| Key | Action |
|-----|--------|
-| `β`/`β` or `j`/`k` | move |
-| `g` / `G` | jump to top / bottom |
-| `space` | toggle selection |
-| `a` / `n` | select all / none |
-| `s` | cycle sort (size β name β age) |
-| `d` or `Del` | delete selection (or current row) β asks `y/N` |
-| `q` / `Esc` | quit |
+| `β` / `β`, `j` / `k` | Move the cursor |
+| `g` / `G` | Jump to top / bottom |
+| `space` | Toggle selection of the current row |
+| `a` / `n` | Select all / select none |
+| `s` | Cycle sort order β size β name β age |
+| `d` / `Del` | Delete the selection (or current row), after a `y/N` confirm |
+| `q` / `Esc` | Quit |
-Sizes β₯ 1 GiB are highlighted in red. Deletion runs in the background, so the UI stays
-responsive even on huge `target/` trees.
+Directories β₯ 1 GiB are highlighted so the big wins jump out.
-## Build
+## π How it works
+
+1. **Discover.** A parallel `jwalk` traversal visits the tree. Whenever a directory
+ contains a `Cargo.toml`, its `target/` child is recorded as a candidate and the
+ walker is told **not** to descend into it β so the (potentially massive) build
+ artifacts are never walked.
+2. **Size.** The instant a candidate is found, a sizing task is dispatched onto a
+ separate thread pool. Sizes stream back into the UI as they complete, independent
+ of the ongoing discovery walk.
+3. **Delete.** Selected directories are removed with `std::fs::remove_dir_all` on a
+ background thread, so even multi-gigabyte deletes keep the interface responsive.
+
+Discovery and sizing run on independent multi-threaded pools, so a slow size
+computation never stalls discovery, and vice-versa.
+
+> β οΈ `targkill` performs a **permanent** delete β there is no recycle bin / trash.
+> Deleting a `target/` is harmless (Cargo regenerates it on the next build), but
+> double-check the path before confirming.
+
+## π οΈ Building
```sh
-cargo build --release # binary at target/release/targkill
+git clone https://github.com/M4cs/targkill
+cd targkill
+cargo build --release # binary at target/release/cargo-targkill
```
+
+Requires a recent stable Rust toolchain (`rustup` recommended).
+
+## π€ Contributing
+
+Issues and pull requests are welcome! CI runs `rustfmt`, `clippy -D warnings`, and the
+test suite across Linux, macOS, and Windows β please run `cargo fmt` and
+`cargo clippy --all-targets -- -D warnings` before opening a PR.
+
+## π License
+
+[MIT](./LICENSE) Β© Max Bridgland ([@M4cs](https://github.com/M4cs))
diff --git a/src/app.rs b/src/app.rs
index 622fa8d..467437c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -213,9 +213,9 @@ impl App {
match self.sort {
Sort::Size => self
.items
- .sort_by(|a, b| b.size.unwrap_or(0).cmp(&a.size.unwrap_or(0))),
+ .sort_by_key(|it| std::cmp::Reverse(it.size.unwrap_or(0))),
Sort::Name => self.items.sort_by(|a, b| a.path.cmp(&b.path)),
- Sort::Age => self.items.sort_by(|a, b| a.modified.cmp(&b.modified)),
+ Sort::Age => self.items.sort_by_key(|it| it.modified),
}
if let Some(anchor) = anchor {
diff --git a/src/main.rs b/src/main.rs
index 599ba6e..7d27b5d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,7 +11,9 @@ use std::sync::mpsc;
use std::time::Duration;
use crossterm::{
- event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers},
+ event::{
+ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
+ },
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
@@ -165,7 +167,11 @@ fn list_mode(roots: Vec, threads: usize) -> io::Result<()> {
let s = sizes.get(p).copied().unwrap_or(0);
println!("{:>10} {}", human_size(s), p.display());
}
- println!("\n{} target dirs Β· {} total", order.len(), human_size(total));
+ println!(
+ "\n{} target dirs Β· {} total",
+ order.len(),
+ human_size(total)
+ );
Ok(())
}
@@ -248,7 +254,11 @@ fn run(
/// How many directories a delete action would remove right now.
fn pending_count(app: &App) -> usize {
- let selected = app.items.iter().filter(|i| i.selected && i.is_live()).count();
+ let selected = app
+ .items
+ .iter()
+ .filter(|i| i.selected && i.is_live())
+ .count();
if selected > 0 {
return selected;
}
diff --git a/src/scan.rs b/src/scan.rs
index 9892d5d..cef40f2 100644
--- a/src/scan.rs
+++ b/src/scan.rs
@@ -45,7 +45,10 @@ impl Sizing {
fn try_finish(&self, tx: &Sender) {
if self.scan_done.load(SeqCst)
&& self.outstanding.load(SeqCst) == 0
- && self.finished.compare_exchange(false, true, SeqCst, SeqCst).is_ok()
+ && self
+ .finished
+ .compare_exchange(false, true, SeqCst, SeqCst)
+ .is_ok()
{
let _ = tx.send(Msg::SizingDone);
}
@@ -118,11 +121,9 @@ fn find_targets(
});
if has_cargo {
// Prune the target dir so we don't walk its (huge) contents.
- for res in children.iter_mut() {
- if let Ok(e) = res {
- if e.file_type().is_dir() && e.file_name() == "target" {
- e.read_children_path = None;
- }
+ for e in children.iter_mut().flatten() {
+ if e.file_type().is_dir() && e.file_name() == "target" {
+ e.read_children_path = None;
}
}
}
@@ -134,7 +135,7 @@ fn find_targets(
}
// Count directories visited and report progress periodically.
*scanned += 1;
- if *scanned % 256 == 0 {
+ if (*scanned).is_multiple_of(256) {
let _ = tx.send(Msg::Progress {
dirs_scanned: *scanned,
});
diff --git a/src/ui.rs b/src/ui.rs
index d1201d2..2549f2e 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -27,12 +27,13 @@ pub fn draw(f: &mut Frame, app: &mut App) {
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
- let block = Block::default()
- .borders(Borders::ALL)
- .title(Span::styled(
- " targkill ",
- Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD),
- ));
+ let block = Block::default().borders(Borders::ALL).title(Span::styled(
+ " targkill ",
+ Style::default()
+ .fg(Color::Black)
+ .bg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
+ ));
let inner = block.inner(area);
f.render_widget(block, area);
@@ -48,7 +49,9 @@ fn draw_header(f: &mut Frame, area: Rect, app: &App) {
Span::raw(" β’ "),
Span::styled(
format!("reclaimable {}", human_size(reclaimable)),
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
),
Span::raw(" β’ "),
Span::styled(
@@ -70,7 +73,10 @@ fn draw_progress(f: &mut Frame, area: Rect, app: &App) {
let r = 0.5 - 0.5 * (phase * std::f64::consts::TAU).cos(); // 0β1β0 triangle-ish
(
r,
- format!("scanning Β· {} dirs visited Β· {} targets", app.scanned_dirs, total),
+ format!(
+ "scanning Β· {} dirs visited Β· {} targets",
+ app.scanned_dirs, total
+ ),
Color::Blue,
)
} else if app.sizing {
@@ -79,7 +85,11 @@ fn draw_progress(f: &mut Frame, area: Rect, app: &App) {
} else {
app.sized_count as f64 / total as f64
};
- (r, format!("sizing {}/{}", app.sized_count, total), Color::Magenta)
+ (
+ r,
+ format!("sizing {}/{}", app.sized_count, total),
+ Color::Magenta,
+ )
} else {
(1.0, format!("done Β· {} targets", total), Color::Green)
};
@@ -89,7 +99,9 @@ fn draw_progress(f: &mut Frame, area: Rect, app: &App) {
.ratio(ratio.clamp(0.0, 1.0))
.label(Span::styled(
label,
- Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
+ Style::default()
+ .fg(Color::White)
+ .add_modifier(Modifier::BOLD),
));
f.render_widget(gauge, area);
}
@@ -118,7 +130,9 @@ fn draw_list(f: &mut Frame, area: Rect, app: &mut App) {
};
let path_style = match &it.status {
- Status::Deleted => Style::default().fg(Color::DarkGray).add_modifier(Modifier::CROSSED_OUT),
+ Status::Deleted => Style::default()
+ .fg(Color::DarkGray)
+ .add_modifier(Modifier::CROSSED_OUT),
Status::Error(_) => Style::default().fg(Color::Red),
_ if it.selected => Style::default().fg(Color::Yellow),
_ => Style::default().fg(Color::White),
@@ -132,7 +146,10 @@ fn draw_list(f: &mut Frame, area: Rect, app: &mut App) {
let line = Line::from(vec![
Span::styled(format!("{mark} "), Style::default().fg(Color::DarkGray)),
Span::styled(format!("{size_txt:>9} "), size_style),
- Span::styled(format!("{:>4} ", age_label(it.modified)), Style::default().fg(Color::DarkGray)),
+ Span::styled(
+ format!("{:>4} ", age_label(it.modified)),
+ Style::default().fg(Color::DarkGray),
+ ),
Span::styled(path_txt, path_style),
]);
ListItem::new(line)
@@ -141,7 +158,11 @@ fn draw_list(f: &mut Frame, area: Rect, app: &mut App) {
let list = List::new(rows)
.block(Block::default().borders(Borders::TOP))
- .highlight_style(Style::default().bg(Color::Indexed(236)).add_modifier(Modifier::BOLD))
+ .highlight_style(
+ Style::default()
+ .bg(Color::Indexed(236))
+ .add_modifier(Modifier::BOLD),
+ )
.highlight_symbol("β");
f.render_stateful_widget(list, area, &mut app.list_state);
@@ -157,7 +178,9 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("β freed {}", human_size(freed)),
- Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
+ Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD),
));
}
if app.scanning || app.sizing {