Skip to content
Open
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ dedicated `feat(...): ... (P<N>)` commit; see those for design details.


### Added
- `disky free [PATH]` — live `statvfs` probe of the volume holding `PATH`
(default `$HOME`). Emits `{path, total_bytes, free_bytes, used_bytes,
used_pct}` under `kind="volume"`. No snapshot and no scan required, so a
cleanup loop can gate on "keep 30 GB free" without a 3-minute traversal.
`stats` answers what a past scan measured; this answers what the filesystem
says now. `free_bytes` uses `f_bavail` (unprivileged-writable), not the
root-only figure.
- New `volume` module: `volume::probe(path)` and `volume::free_bytes(path)`.
The latter returns `None` on probe failure — never `0`, which would read as
a full disk.

### Changed
- `disky predict` defaults `--free-bytes` to the live `$HOME`-volume free
space. Previously omitting the flag produced `fill_at: null` with reason
"provide --free-bytes", which made the command useless by default and
forced callers into `df -k / | awk '{print $4*1024}'`.
- `disky growth --over-n` defaults `--fill-target` the same way. Its `--help`
already claimed this default; the code never implemented it.

- `disky cleanup` (CLI + MCP) now adds `summary: [CategorySummary]` and
`total_bytes` to the JSON envelope, aggregating hits across paths per
category. Text mode prints a second table beneath the per-path list
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ ratatui = "0.29"
crossterm = "0.28"
arboard = "3"
dirs = "5"
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ctrlc = "3"
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ brew install biliboss/tap/disky
disky scan / # scan from root (~3 min for 2M files)
disky # open ncdu-style TUI

disky free # live free space on the $HOME volume (no scan)
disky free /Volumes/EXT # …or any other volume
disky top --physical # largest files (physical bytes — APFS-aware)
disky dirs --physical # largest directories
disky ext # usage by extension
Expand All @@ -80,6 +82,7 @@ disky scan / # before
disky scan / # after
disky diff @latest~1 @latest # what grew, shrank, was added or removed
disky growth --over-n 5 # OLS fit across 5 most-recent snapshots
disky predict # fill-by date; free-byte budget probed live
```

### Agent-native output
Expand Down
21 changes: 15 additions & 6 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,24 +217,33 @@ pub enum Command {
#[arg(long, value_name = "N")]
over_n: Option<usize>,
/// Free-byte budget the projection extrapolates against. Only used
/// with `--over-n`. Default: free bytes on the volume that holds
/// `$HOME` (best-effort; may be 0 if `statvfs` fails).
/// with `--over-n`. Default: live free bytes on the volume that holds
/// `$HOME` (omitted from the projection if `statvfs` fails).
#[arg(long, value_name = "BYTES")]
fill_target: Option<u64>,
#[arg(short, long, default_value_t = 50)]
limit: usize,
},

/// Predict when the disk fills based on linear regression over all
/// snapshots in the data dir. Pass `--free-bytes <N>` so the fit
/// can compute a fill-by date.
/// snapshots in the data dir. Defaults the fill target to the live free
/// space on the `$HOME` volume — pass `--free-bytes <N>` to override.
Predict {
/// Bytes currently free on the volume. Get via
/// `df -k / | tail -1 | awk '{print $4*1024}'`.
/// Bytes currently free on the volume. Default: probed live via
/// `statvfs` on the `$HOME` volume (see `disky free`).
#[arg(long, value_name = "BYTES")]
free_bytes: Option<u64>,
},

/// Live free space on a volume — no snapshot needed, no scan needed.
/// Reports total / free / used bytes and used percent for the volume
/// holding PATH. This is the number to gate a cleanup loop on; `stats`
/// reports what a past scan measured, which is a different question.
Free {
/// Path whose volume to probe. Default: `$HOME`.
path: Option<std::path::PathBuf>,
},

/// Per-directory churn — files modified within the last N hours/days.
/// Identifies log generators and hot working directories.
Churn {
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ pub mod render;
pub mod scan;
pub mod schema;
pub mod snapshots;
pub mod volume;
28 changes: 27 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ fn dispatch(cli: Cli, format: Format) -> anyhow::Result<()> {
}
// list_snapshots() returns oldest..newest; take the last N.
let pick: Vec<(String, i64)> = parsed[parsed.len() - n..].to_vec();
// Same default as `predict`: probe the volume when unset.
let fill_target = fill_target.or_else(|| disky::volume::free_bytes(None));
let rows = query::growth_over_n(&pick, limit, fill_target)?;
if format.is_machine() {
let payload = json!({
Expand Down Expand Up @@ -416,7 +418,10 @@ fn dispatch(cli: Cli, format: Format) -> anyhow::Result<()> {
}
}
Command::Predict { free_bytes } => {
let rec = disky::predict::predict(physical, free_bytes)?;
// Without a budget the fit has nothing to extrapolate against, so
// fall back to the live volume rather than emitting a null date.
let budget = free_bytes.or_else(|| disky::volume::free_bytes(None));
let rec = disky::predict::predict(physical, budget)?;
if format.is_machine() {
let payload = json!({
"schema_version": SCHEMA_VERSION,
Expand Down Expand Up @@ -445,6 +450,27 @@ fn dispatch(cli: Cli, format: Format) -> anyhow::Result<()> {
}
}
}
Command::Free { path } => {
let target = path
.or_else(dirs::home_dir)
.ok_or_else(|| DiskyError::new(ExitCode::Io, "no path", "home dir unavailable"))?;
let v = disky::volume::probe(&target)
.map_err(|e| DiskyError::new(ExitCode::Io, "statvfs failed", e.to_string()))?;
if format.is_machine() {
let payload = json!({
"schema_version": SCHEMA_VERSION,
"kind": "volume",
"records": [&v],
});
println!("{}", payload);
} else {
let gb = |b: u64| b as f64 / 1024.0_f64.powi(3);
println!("Path: {}", v.path);
println!("Total: {:>10.1} GB", gb(v.total_bytes));
println!("Used: {:>10.1} GB ({:.0}%)", gb(v.used_bytes), v.used_pct);
println!("Free: {:>10.1} GB", gb(v.free_bytes));
}
}
Command::Churn {
over,
snapshot,
Expand Down
1 change: 1 addition & 0 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ fn commands() -> Value {
{ "name": "stats", "args": snapshot_with(&["summarize:bool", "raw:bool"]), "output": "Stats | Scalar (with --summarize) | raw u64 (with --raw)" },
{ "name": "query", "args": snapshot_with(&["sql:string", "limit:int=1000"]), "output": "Object[]" },
{ "name": "list", "args": {}, "output": "Snapshot[]" },
{ "name": "free", "args": { "path": "path? — default $HOME" }, "output": "VolumeRecord[] under kind='volume'. Record: {path, total_bytes:u64, free_bytes:u64, used_bytes:u64, used_pct:f64}. Live statvfs — no snapshot required." },
{
"name": "diff",
"args": { "a": "@latest|<id>|<path>", "b": "@latest|<id>|<path>", "limit": "int=100" },
Expand Down
104 changes: 104 additions & 0 deletions src/volume.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Live volume free-space probe.
//!
//! A snapshot cannot carry this number: `stats` reports what a scan measured
//! at scan time, while a cleanup guard needs what the filesystem says *right
//! now*. It is also the missing input for [`crate::predict`] and
//! `growth --over-n`, whose fill-by projections are inert without a byte
//! budget to extrapolate against — before this existed the caller had to
//! shell out to `df` and do the unit arithmetic by hand.

use anyhow::{anyhow, Result};
use serde::Serialize;
use std::ffi::CString;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize)]
pub struct VolumeRecord {
/// Path that was probed (not the mount point — the volume is whichever
/// one holds this path).
pub path: String,
pub total_bytes: u64,
/// Blocks available to an unprivileged process. Smaller than the
/// root-only free space on filesystems that reserve a margin, which is
/// the honest number for "can I still write files".
pub free_bytes: u64,
pub used_bytes: u64,
/// 0..100, computed as `used / total`. Reads 1–2 points *above* `df`'s
/// Capacity column, which divides by `used + available` and so hides the
/// root-reserved margin. Neither is wrong; this one is the fraction of the
/// whole volume.
pub used_pct: f64,
}

/// `statvfs(2)` on the volume holding `path`.
pub fn probe(path: &Path) -> Result<VolumeRecord> {
let c_path = CString::new(path.as_os_str().as_encoded_bytes())
.map_err(|_| anyhow!("path contains an interior NUL byte: {}", path.display()))?;

// SAFETY: c_path outlives the call; statvfs only reads it. The buffer is
// fully written by the syscall on success and never read on failure.
let stat = unsafe {
let mut buf: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c_path.as_ptr(), &mut buf) != 0 {
return Err(std::io::Error::last_os_error())
.map_err(|e| anyhow!("statvfs({}) failed: {}", path.display(), e));
}
buf
};

// f_frsize is the fragment size the block counts are denominated in;
// f_bsize is the preferred I/O size and is the wrong multiplier here.
let unit = stat.f_frsize as u64;
let total_bytes = (stat.f_blocks as u64).saturating_mul(unit);
let free_bytes = (stat.f_bavail as u64).saturating_mul(unit);
let used_bytes = total_bytes.saturating_sub(free_bytes);
let used_pct = if total_bytes == 0 {
0.0
} else {
used_bytes as f64 / total_bytes as f64 * 100.0
};

Ok(VolumeRecord {
path: path.display().to_string(),
total_bytes,
free_bytes,
used_bytes,
used_pct,
})
}

/// Free bytes on the volume holding `path`, or the `$HOME` volume when
/// `None`. Returns `None` on any failure — callers must treat that as
/// "unknown" and never as zero, or a probe error reads as a full disk.
pub fn free_bytes(path: Option<&Path>) -> Option<u64> {
let owned: Option<PathBuf> = match path {
Some(p) => Some(p.to_path_buf()),
None => dirs::home_dir(),
};
probe(owned?.as_path()).ok().map(|v| v.free_bytes)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn probe_root_is_internally_consistent() {
let v = probe(Path::new("/")).expect("root volume must be probeable");
assert!(v.total_bytes > 0, "total must be non-zero");
assert!(v.free_bytes <= v.total_bytes);
assert_eq!(v.used_bytes, v.total_bytes - v.free_bytes);
assert!((0.0..=100.0).contains(&v.used_pct));
}

#[test]
fn probe_missing_path_errors() {
// Negative control: a bad path must fail loudly, not report 0 free.
assert!(probe(Path::new("/nonexistent-disky-probe-target")).is_err());
}

#[test]
fn free_bytes_defaults_to_home_volume() {
assert!(free_bytes(None).unwrap_or(0) > 0);
}
}
Loading