Fast disk usage analyzer with beautiful output and efficient file scanning.
Status: Early stage, not well-tested. API subject to change.
wtfs is a fast directory tree analyzer that shows disk usage with a clean, tabular interface. It includes:
- Fast scanning: Uses efficient bulk attribute retrieval (
getattrlistbulkon macOS, optimized fallbacks on Linux) - Beautiful output: Formatted tables showing directory sizes, file counts, and largest files
- Binary snapshots: Save and load scan results for later analysis or comparison
- Zig library: The underlying library provides type-safe, efficient file attribute scanning
# Build
zig build
# Scan current directory
./zig-out/bin/wtfs .
# Save scan to file
./zig-out/bin/wtfs --binary-output scan.bin .
# Load and view saved scan
./zig-out/bin/wtfs --binary-input scan.bin
# Show files larger than 1MB
./zig-out/bin/wtfs --large-file-threshold 1M .Requires Zig 0.15.1 or later.
# Run directly
nix run github:mbrock/wtfs -- ~
# Install to profile
nix profile install github:mbrock/wtfsgit clone https://github.com/mbrock/wtfs
cd wtfs
zig build
./zig-out/bin/wtfs --helpzig fetch --save git+https://github.com/mbrock/wtfsThen in your build.zig:
const wtfs = b.dependency("wtfs", .{});
exe.root_module.addImport("wtfs", wtfs.module("wtfs"));usage: wtfs [--skip-hidden] [--large-file-threshold SIZE] [--binary-output PATH] [--binary-input PATH] [--dump-structs] [dir]
SIZE accepts optional K/M/G/T suffix (base 1024)
--binary-input: load scan results from file instead of scanning
.: 243 dirs, 497 files, 113.6MiB total
Top-level directories by total size:
┌──────────────┬─────────────┬─────────┬──────────────┬─────────────┐
│ Directory │ Size │ Share │ Files │ Dirs │
├──────────────┼─────────────┼─────────┼──────────────┼─────────────┤
│ ./.zig-cache │ 92.0 MiB │ 81.0% │ 42 │ 20 │
│ ./zig-out │ 14.6 MiB │ 12.9% │ 3 │ 1 │
│ ./.git │ 5.5 MiB │ 4.9% │ 411 │ 215 │
│ ./src │ 200.0 KiB │ 0.2% │ 14 │ 0 │
└──────────────┴─────────────┴─────────┴──────────────┴─────────────┘
Heaviest directories in tree:
┌────────────────────────────────────────┬─────────────┬─────────┐
│ Directory │ Size │ Share │
├────────────────────────────────────────┼─────────────┼─────────┤
│ . │ 113.6 MiB │ 100.0% │
│ .zig-cache │ 92.0 MiB │ 81.0% │
│ o │ 91.3 MiB │ 80.4% │
│ 6463279db290e53d329669813725e8cb │ 21.6 MiB │ 19.0% │
└────────────────────────────────────────┴─────────────┴─────────┘
Save scan results to a compact binary format for later analysis:
# Save snapshot
./zig-out/bin/wtfs --binary-output snapshot.bin /large/directory
# Load and analyze later (instant - no rescanning)
./zig-out/bin/wtfs --binary-input snapshot.binBinary format is wtfsdumpv1 - a compact representation that preserves:
- Complete directory tree structure with aggregated totals
- File and directory counts per directory
- Size information
- Large file tracking (>= 100 MB by default)
Typical compression: 200+ directories → ~9 KB binary file.
Python package available: Uses uv for fast, reliable package management. See tools/README.md for details.
# Install dependencies
uv sync
# Use the Scanner to scan and analyze
from wtfs import Scanner, dump
scanner = Scanner()
results = scanner.scan('.') # Uses wtfs binary with full threading
print(f"Found {results['directories']} directories")
# Analyze the results with rich output
data = dump.load(results['dump_file'])
# ... beautiful tables and analysis ...
# Or use the CLI directly
uv run wtfsdump scan.bin --top 50
uv run wtfsdump scan.bin --find dockerThe Python package includes a beautiful interactive terminal UI powered by Textual:
# Scan and launch interactive browser
uv run wtfs -i .
# Or load an existing scan
uv run wtfs --load scan.bin -i
# Or use the dedicated TUI command
uv run wtfs-tui scan.binFeatures:
- 📊 Real-time filtering - Type to filter directories as you browse
- 🔄 Multiple sort modes - Sort by size (
s), name (n), or file count (f) - 📑 Tabbed interface - Switch between directories and large files views
- ⌨️ Keyboard shortcuts - Full keyboard navigation (arrow keys, Tab, etc.)
- 🎨 Beautiful UI - Clean, modern interface with colors and formatting
Keyboard Shortcuts:
s- Sort by sizen- Sort by namef- Sort by file countTab- Switch between Directories and Large Files tabsq- QuitCtrl+P- Command palettem- Mark or unmark the selected directorya- Archive marked directories to a mounted volume or chosen folderd- Review and permanently delete marked directories
Deletion runs in the background with live entry and target progress. Independent marked directories are removed concurrently. wtfs repairs owner access bits on read-only contents when possible; targets that still fail remain marked and get a detailed report with the failing path and permission troubleshooting steps.
Archiving creates a timestamped wtfs-archive-* directory at the chosen
destination and mirrors each marked directory's path beneath it. Moves on one
filesystem use an atomic rename; moves to an external volume stream the copy in
the background with byte progress, preserve metadata and symbolic links, and
remove the source only after the copy completes. Each archive includes a
manifest.json recording original paths and outcomes. Mounted volumes such as
/Volumes/Lootbox appear in the destination prompt automatically.
Deletion is enabled automatically after a fresh scan. When opening a saved dump directly, provide its original scan root:
uv run wtfs-tui scan.bin --root /path/that/was/scannedThe interactive mode makes it easy to explore large directory structures and quickly find what's taking up space!
For advanced users who want to use wtfs as a library for custom file scanning:
const std = @import("std");
const wtfs = @import("wtfs");
// Open directory with .iterate flag (required for getattrlistbulk)
const dir = try std.fs.cwd().openDir(".", .{ .iterate = true });
defer dir.close();
// Configure which attributes to retrieve at compile time
const mask = wtfs.AttrGroupMask{
.common = .{
.name = true, // File/directory name
.obj_type = true, // Object type (file/dir/symlink)
.file_id = true, // inode number
},
.dir = .{
.entry_count = true, // Number of entries in directory
},
.file = .{
.total_size = true, // Logical file size
.alloc_size = true, // Allocated size on disk
},
};
// The scanner type is generated at compile time based on your mask
const Scanner = wtfs.DirScanner(mask);
// Provide your own buffer for the syscall results
var buffer: [16384]u8 = undefined;
var scanner = Scanner.init(dir.handle, &buffer);
// Iterate through entries in batches
while (true) {
if (!(try scanner.fill())) break;
while (try scanner.next()) |entry| {
std.debug.print("{s}: ", .{entry.name});
switch (entry.kind) {
.dir => {
std.debug.print("directory with {} entries\n", .{entry.details.dir.entrycount});
},
.file => {
std.debug.print("file, {}B ({}B allocated)\n", .{
entry.details.file.totalsize,
entry.details.file.allocsize,
});
},
.symlink => std.debug.print("symlink\n", .{}),
.other => std.debug.print("other\n", .{}),
}
}
}On non-macOS targets next() will fetch entries on demand, so the outer
fill() loop is optional. macOS callers must keep the fill() call because it
performs the underlying getattrlistbulk syscall.
The key feature is that the EntryFor(mask) function generates a struct at compile time with only the fields you've requested:
// Request only name and type
const minimal_mask = wtfs.AttrGroupMask{
.common = .{ .name = true, .obj_type = true },
};
// Entry will only have .name and .kind fields
const MinimalEntry = wtfs.EntryFor(minimal_mask);
// Request everything
const full_mask = wtfs.AttrGroupMask{
.common = .{
.name = true,
.obj_type = true,
.file_id = true,
.fsid = true,
},
.dir = .{
.linkcount = true,
.entrycount = true,
.allocsize = true,
},
.file = .{
.linkcount = true,
.totalsize = true,
.allocsize = true,
},
};
// Entry will have all requested fields
const FullEntry = wtfs.EntryFor(full_mask);MIT License - See LICENSE file for details.
Contributions are welcome! Please feel free to submit pull requests or open issues.
Mikael Brockman (@mbrock)