Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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 }}
163 changes: 124 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,66 +1,151 @@
# targkill
<div align="center">

# 🦀 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.

[![CI](https://github.com/M4cs/targkill/actions/workflows/ci.yml/badge.svg)](https://github.com/M4cs/targkill/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/targkill.svg)](https://crates.io/crates/targkill)
[![Downloads](https://img.shields.io/crates/d/targkill.svg)](https://crates.io/crates/targkill)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)

</div>

---

```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 <N>` | 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))
4 changes: 2 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -165,7 +167,11 @@ fn list_mode(roots: Vec<PathBuf>, 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(())
}

Expand Down Expand Up @@ -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;
}
Expand Down
15 changes: 8 additions & 7 deletions src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ impl Sizing {
fn try_finish(&self, tx: &Sender<Msg>) {
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);
}
Expand Down Expand Up @@ -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;
}
}
}
Expand All @@ -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,
});
Expand Down
Loading
Loading