From d82819961d7d87e3dd64d0ae67d2f68c9c4632a6 Mon Sep 17 00:00:00 2001 From: Max Bridgland Date: Mon, 29 Jun 2026 11:47:19 -0400 Subject: [PATCH] Add CI, release automation, crates.io publishing, and polished README - ci.yml: rustfmt, clippy (-D warnings), and build/test on Linux/macOS/Windows - release.yml: on v* tags, create a GitHub Release, upload binaries for 5 targets (linux x64/arm64, macOS x64/arm64, windows x64), and publish to crates.io - Fix clippy lints (manual_flatten, manual_is_multiple_of, unnecessary_sort_by) and apply rustfmt so CI passes - Rewrite README in npkill style: badges, features, install, usage, keybindings, how-it-works, and a TUI preview Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 49 ++++++++++ .github/workflows/release.yml | 65 ++++++++++++++ README.md | 163 ++++++++++++++++++++++++++-------- src/app.rs | 4 +- src/main.rs | 16 +++- src/scan.rs | 15 ++-- src/ui.rs | 51 ++++++++--- 7 files changed, 298 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml 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. + +[![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) + +
+ +--- + +```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 {