diff --git a/CHANGELOG.md b/CHANGELOG.md index b49aa89..4686942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +## What's Changed + +* Add `hardcache local status` command with human-readable and `--json` output. +* Move trim-only flags (`--unused-for`, `--max-size`) under `trim` and `trimd` commands. + ## [v0.2.0](https://github.com/AlekSi/hardcache/releases/tag/v0.2.0) (2025-12-07) ## What's Changed diff --git a/README.md b/README.md index 66c1267..01ed9a8 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Hardcache is a tool for managing the Go build cache. -The initial public version supports only a more flexible trimming policy of a standard local cache. -More functionality will be published soon, including support for `GOCACHEPROG`. +It currently supports local cache status reporting and a more flexible trimming policy +of a standard local cache. More functionality will be published soon, including support +for `GOCACHEPROG`. ## Installation @@ -69,6 +70,20 @@ hardcache local trimd --unused-for=2w --max-size=10GB --interval=1h Using `trimd` subcommand instead of `trim` triggers trimming every specified interval. +#### Status + +Display current cache and disk usage stats: + +``` +hardcache local status +``` + +Use compact JSON output for scripting: + +``` +hardcache local status --json +``` + ## Credits This tool is written by me, Alexey Palazhchenko, diff --git a/go.mod b/go.mod index e2239b2..8540c64 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/AlekSi/hardcache -go 1.25 +go 1.25.0 + +toolchain go1.26.2 // https://go.dev/doc/godebug#go-120 // https://pkg.go.dev/archive/tar#Reader.Next @@ -11,12 +13,12 @@ godebug ( ) require ( - github.com/AlekSi/lazyerrors v0.5.0 - github.com/alecthomas/kong v1.14.0 + github.com/AlekSi/lazyerrors v0.6.0 + github.com/alecthomas/kong v1.15.0 github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b github.com/stretchr/testify v1.11.1 github.com/xhit/go-str2duration/v2 v2.1.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.43.0 ) require ( diff --git a/go.sum b/go.sum index dccd625..10d51db 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -github.com/AlekSi/lazyerrors v0.5.0 h1:c4zM7P9S2seDJJ5UcEh9hayVVKGck/i7XE+JE6XXayI= -github.com/AlekSi/lazyerrors v0.5.0/go.mod h1:uipmPehsHYFEViLPI0rSdXu7WvPcbQ2jfhkdik4kKb4= +github.com/AlekSi/lazyerrors v0.6.0 h1:TX4iCP7N+YOuOmUl6Gv5OQ7brhPzPPrSsEAm+at+avk= +github.com/AlekSi/lazyerrors v0.6.0/go.mod h1:TresBdOmCoC169IDo09YbrYUDXiqRwlLpInBeS1qD2g= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= -github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= +github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= @@ -27,8 +27,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/caches/local/local.go b/internal/caches/local/local.go index 300cfad..efb0dd4 100644 --- a/internal/caches/local/local.go +++ b/internal/caches/local/local.go @@ -20,6 +20,14 @@ type Cache struct { l *slog.Logger } +// Stats describes local cache state. +type Stats struct { + Entries int + Bytes int64 + Oldest *time.Time + Newest *time.Time +} + // New creates a new [Cache]. func New(dir string, cutoff *time.Time, maxSize *int64, l *slog.Logger) (*Cache, error) { dc, err := cache.Open(dir) @@ -68,6 +76,17 @@ func (c *Cache) TrimForce() (before, freed int64) { return c.dc.TrimForce(c.cutoff, c.maxSize, c.l) } +// Status returns current local cache statistics. +func (c *Cache) Status() Stats { + s := c.dc.Stats(c.l) + return Stats{ + Entries: s.Entries, + Bytes: s.Bytes, + Oldest: s.Oldest, + Newest: s.Newest, + } +} + // check interfaces var ( _ cache.Cache = (*Cache)(nil) diff --git a/internal/caches/local/local_test.go b/internal/caches/local/local_test.go index 071febf..605d791 100644 --- a/internal/caches/local/local_test.go +++ b/internal/caches/local/local_test.go @@ -192,3 +192,30 @@ func TestTrimSizePart(t *testing.T) { assert.EqualValues(t, 60_023_595, freed) assert.Less(t, before-freed, maxSize) } + +func TestStatusFixture(t *testing.T) { + t.Parallel() + + c, err := New(setup(t), nil, nil, logger(t)) + require.NoError(t, err) + + stats := c.Status() + assert.EqualValues(t, 1219, stats.Entries) + assert.EqualValues(t, 109_518_524, stats.Bytes) + require.NotNil(t, stats.Oldest) + require.NotNil(t, stats.Newest) + assert.True(t, stats.Oldest.Before(*stats.Newest)) +} + +func TestStatusEmpty(t *testing.T) { + t.Parallel() + + c, err := New(t.TempDir(), nil, nil, logger(t)) + require.NoError(t, err) + + stats := c.Status() + assert.EqualValues(t, 0, stats.Entries) + assert.EqualValues(t, 0, stats.Bytes) + assert.Nil(t, stats.Oldest) + assert.Nil(t, stats.Newest) +} diff --git a/internal/go/cache/cache_extra.go b/internal/go/cache/cache_extra.go index fd43c91..137c862 100644 --- a/internal/go/cache/cache_extra.go +++ b/internal/go/cache/cache_extra.go @@ -20,6 +20,14 @@ import ( // EntryNotFoundError is exported for use in other packages. type EntryNotFoundError = entryNotFoundError +// Stats describes cache state derived from a full directory scan. +type Stats struct { + Entries int + Bytes int64 + Oldest *time.Time + Newest *time.Time +} + // fileInfo represents information about a file or directory with executable in the cache. // The order of fields is weird to make struct smaller. type fileInfo struct { @@ -128,6 +136,29 @@ func (c *DiskCache) TrimForce(cutoff *time.Time, maxSize *int64, l *slog.Logger) return } +// Stats scans the cache directory and returns aggregate statistics. +func (c *DiskCache) Stats(l *slog.Logger) Stats { + files, bytes := c.read(l) + stats := Stats{ + Entries: len(files), + Bytes: bytes, + } + + for _, fi := range files { + modTime := fi.ModTime + if stats.Oldest == nil || modTime.Before(*stats.Oldest) { + v := modTime + stats.Oldest = &v + } + if stats.Newest == nil || modTime.After(*stats.Newest) { + v := modTime + stats.Newest = &v + } + } + + return stats +} + // read reads the entire cache directory. func (c *DiskCache) read(l *slog.Logger) (files []fileInfo, before int64) { files = make([]fileInfo, 0, 256) diff --git a/main.go b/main.go index 030c259..d5c2554 100644 --- a/main.go +++ b/main.go @@ -23,13 +23,21 @@ import ( //nolint:vet // for readability var cli struct { Local struct { - Dir string `default:"${local_dir_default}" type:"path" help:"Directory to use."` - UnusedFor unit.Duration `default:"5d" help:"Always remove entries unused for this duration. Pass 0 to disable."` - MaxSize string `default:"0GB" help:"${local_max_size_help}"` + Dir string `default:"${local_dir_default}" type:"path" help:"Directory to use."` + + Status struct { + JSON bool `help:"Output as compact JSON."` + } `cmd:"" help:"Show local cache status."` + + Trim struct { + UnusedFor unit.Duration `default:"5d" help:"Always remove entries unused for this duration. Pass 0 to disable."` + MaxSize string `default:"0GB" help:"${local_max_size_help}"` + } `cmd:"" help:"Trim local cache."` - Trim struct{} `cmd:"" help:"Trim local cache."` Trimd struct { - Interval unit.Duration `short:"i" default:"1h" help:"Interval between trimmings."` + UnusedFor unit.Duration `default:"5d" help:"Always remove entries unused for this duration. Pass 0 to disable."` + MaxSize string `default:"0GB" help:"${local_max_size_help}"` + Interval unit.Duration `short:"i" default:"1h" help:"Interval between trimmings."` } `cmd:"" help:"Trim local cache continuously."` } `cmd:""` @@ -52,26 +60,26 @@ var GOCACHE = sync.OnceValue(func() string { return strings.TrimSpace(string(b)) }) -// localTrim force-trims local cache according to CLI flags. -func localTrim(l *slog.Logger) error { - if cli.Local.UnusedFor < 0 { - return fmt.Errorf("--unused-for cannot be negative: %d", cli.Local.UnusedFor) +// localTrim force-trims local cache according to parameters. +func localTrim(dir string, unusedFor unit.Duration, maxSize string, l *slog.Logger) error { + if unusedFor < 0 { + return fmt.Errorf("--unused-for cannot be negative: %d", unusedFor) } var cutoff *time.Time - if cli.Local.UnusedFor > 0 { - c := time.Now().Add(-time.Duration(cli.Local.UnusedFor)) + if unusedFor > 0 { + c := time.Now().Add(-time.Duration(unusedFor)) cutoff = &c } var b unit.Bytes - if strings.HasSuffix(cli.Local.MaxSize, "%") { + if strings.HasSuffix(maxSize, "%") { var p unit.Percentage - if err := p.UnmarshalText([]byte(cli.Local.MaxSize)); err != nil { + if err := p.UnmarshalText([]byte(maxSize)); err != nil { return err } - total, _, err := local.DiskInfo(cli.Local.Dir) + total, _, err := local.DiskInfo(dir) if err != nil { return err } @@ -86,7 +94,7 @@ func localTrim(l *slog.Logger) error { slog.String("max_size", b.String()), ) } else { - if err := b.UnmarshalText([]byte(cli.Local.MaxSize)); err != nil { + if err := b.UnmarshalText([]byte(maxSize)); err != nil { return err } @@ -102,7 +110,7 @@ func localTrim(l *slog.Logger) error { maxSize = (*int64)(&b) } - c, err := local.New(cli.Local.Dir, cutoff, maxSize, l) + c, err := local.New(dir, cutoff, maxSize, l) if err != nil { return err } @@ -151,17 +159,21 @@ func main() { defer cancel() switch kongCtx.Command() { + case "local status": + err := localStatus(cli.Local.Dir, cli.Local.Status.JSON, l) + kongCtx.FatalIfErrorf(err) + case "local trim": - if time.Duration(cli.Local.UnusedFor) > 5*24*time.Hour { + if time.Duration(cli.Local.Trim.UnusedFor) > 5*24*time.Hour { l.Info("Note: this command should be invoked more often than once per day to keep the cache.") } - err := localTrim(l) + err := localTrim(cli.Local.Dir, cli.Local.Trim.UnusedFor, cli.Local.Trim.MaxSize, l) kongCtx.FatalIfErrorf(err) case "local trimd": for { - err := localTrim(l) + err := localTrim(cli.Local.Dir, cli.Local.Trimd.UnusedFor, cli.Local.Trimd.MaxSize, l) kongCtx.FatalIfErrorf(err) select { diff --git a/main_status.go b/main_status.go new file mode 100644 index 0000000..fa70561 --- /dev/null +++ b/main_status.go @@ -0,0 +1,176 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "math" + "os" + "strings" + "time" + + "github.com/AlekSi/hardcache/internal/caches/local" + "github.com/AlekSi/hardcache/internal/unit" +) + +// localStatusReport contains all calculated values used by status output modes. +type localStatusReport struct { + Directory string + CacheEntries int + CacheBytes int64 + CacheOldest *time.Time + CacheNewest *time.Time + DiskTotalBytes int64 + DiskUsedBytes int64 + DiskFreeBytes int64 + DiskUsedPercent float64 + DiskFreePercent float64 + CacheOfTotalPercent float64 +} + +type jsonLocalStatus struct { + Directory string `json:"directory"` + Cache struct { + Entries int `json:"entries"` + Bytes int64 `json:"bytes"` + Human string `json:"human"` + Oldest *string `json:"oldest"` + Newest *string `json:"newest"` + } `json:"cache"` + Disk struct { + TotalBytes int64 `json:"total_bytes"` + TotalHuman string `json:"total_human"` + UsedBytes int64 `json:"used_bytes"` + UsedHuman string `json:"used_human"` + UsedPercent float64 `json:"used_percent"` + FreeBytes int64 `json:"free_bytes"` + FreeHuman string `json:"free_human"` + FreePercent float64 `json:"free_percent"` + } `json:"disk"` + CacheOfTotalPercent float64 `json:"cache_of_total_percent"` +} + +func localStatus(dir string, asJSON bool, l *slog.Logger) error { + c, err := local.New(dir, nil, nil, l) + if err != nil { + return err + } + + cacheStats := c.Status() + total, free, err := local.DiskInfo(dir) + if err != nil { + return err + } + + report := newLocalStatusReport(dir, cacheStats, total, free) + + var out string + if asJSON { + out, err = renderLocalStatusJSON(report) + if err != nil { + return err + } + } else { + out = renderLocalStatusText(report) + } + + _, err = os.Stdout.WriteString(out) + return err +} + +func newLocalStatusReport(dir string, stats local.Stats, total, free int64) localStatusReport { + used := total - free + if used < 0 { + used = 0 + } + + return localStatusReport{ + Directory: dir, + CacheEntries: stats.Entries, + CacheBytes: stats.Bytes, + CacheOldest: stats.Oldest, + CacheNewest: stats.Newest, + DiskTotalBytes: total, + DiskUsedBytes: used, + DiskFreeBytes: free, + DiskUsedPercent: round2(percentage(used, total)), + DiskFreePercent: round2(percentage(free, total)), + CacheOfTotalPercent: round2(percentage(stats.Bytes, total)), + } +} + +func renderLocalStatusText(report localStatusReport) string { + var b strings.Builder + + fmt.Fprintf(&b, "Directory: %s\n", report.Directory) + fmt.Fprintf(&b, "Cache entries: %d\n", report.CacheEntries) + fmt.Fprintf(&b, "Cache size: %s\n", formatSizeWithRaw(report.CacheBytes)) + fmt.Fprintf(&b, "Oldest entry: %s\n", formatLocalTime(report.CacheOldest)) + fmt.Fprintf(&b, "Newest entry: %s\n", formatLocalTime(report.CacheNewest)) + fmt.Fprintf(&b, "Disk total: %s\n", formatSizeWithRaw(report.DiskTotalBytes)) + fmt.Fprintf(&b, "Disk used: %s (%.2f%%)\n", formatSizeWithRaw(report.DiskUsedBytes), report.DiskUsedPercent) + fmt.Fprintf(&b, "Disk free: %s (%.2f%%)\n", formatSizeWithRaw(report.DiskFreeBytes), report.DiskFreePercent) + fmt.Fprintf(&b, "Cache of total disk: %.2f%%\n", report.CacheOfTotalPercent) + + return b.String() +} + +func renderLocalStatusJSON(report localStatusReport) (string, error) { + payload := jsonLocalStatus{ + Directory: report.Directory, + CacheOfTotalPercent: report.CacheOfTotalPercent, + } + payload.Cache.Entries = report.CacheEntries + payload.Cache.Bytes = report.CacheBytes + payload.Cache.Human = unit.Bytes(report.CacheBytes).String() + payload.Cache.Oldest = formatLocalTimePtr(report.CacheOldest) + payload.Cache.Newest = formatLocalTimePtr(report.CacheNewest) + payload.Disk.TotalBytes = report.DiskTotalBytes + payload.Disk.TotalHuman = unit.Bytes(report.DiskTotalBytes).String() + payload.Disk.UsedBytes = report.DiskUsedBytes + payload.Disk.UsedHuman = unit.Bytes(report.DiskUsedBytes).String() + payload.Disk.UsedPercent = report.DiskUsedPercent + payload.Disk.FreeBytes = report.DiskFreeBytes + payload.Disk.FreeHuman = unit.Bytes(report.DiskFreeBytes).String() + payload.Disk.FreePercent = report.DiskFreePercent + + res, err := json.Marshal(payload) + if err != nil { + return "", err + } + + return string(res) + "\n", nil +} + +func formatSizeWithRaw(size int64) string { + return fmt.Sprintf("%s (%d bytes)", unit.Bytes(size).String(), size) +} + +func formatLocalTime(ts *time.Time) string { + if ts == nil { + return "n/a" + } + + return ts.Local().Format(time.RFC3339) +} + +func formatLocalTimePtr(ts *time.Time) *string { + if ts == nil { + return nil + } + + res := ts.Local().Format(time.RFC3339) + return &res +} + +func percentage(value, total int64) float64 { + if total <= 0 { + return 0 + } + + return float64(value) / float64(total) * 100 +} + +func round2(v float64) float64 { + return math.Round(v*100) / 100 +} diff --git a/main_status_test.go b/main_status_test.go new file mode 100644 index 0000000..2e9c00c --- /dev/null +++ b/main_status_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/AlekSi/hardcache/internal/caches/local" +) + +func TestStatusTextFormatting(t *testing.T) { + oldest := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + newest := time.Date(2026, time.January, 3, 4, 5, 6, 0, time.UTC) + report := newLocalStatusReport("/tmp/cache", local.Stats{ + Entries: 3, + Bytes: 1024, + Oldest: &oldest, + Newest: &newest, + }, 10*1024, 4*1024) + + actual := renderLocalStatusText(report) + + assert.Contains(t, actual, "Directory: /tmp/cache") + assert.Contains(t, actual, "Cache entries: 3") + assert.Contains(t, actual, fmt.Sprintf("Cache size: %s", formatSizeWithRaw(1024))) + assert.Contains(t, actual, fmt.Sprintf("Oldest entry: %s", oldest.Local().Format(time.RFC3339))) + assert.Contains(t, actual, fmt.Sprintf("Newest entry: %s", newest.Local().Format(time.RFC3339))) + assert.Contains(t, actual, fmt.Sprintf("Disk total: %s", formatSizeWithRaw(10*1024))) + assert.Contains(t, actual, fmt.Sprintf("Disk used: %s (60.00%%)", formatSizeWithRaw(6*1024))) + assert.Contains(t, actual, fmt.Sprintf("Disk free: %s (40.00%%)", formatSizeWithRaw(4*1024))) + assert.Contains(t, actual, "Cache of total disk: 10.00%") +} + +func TestStatusTextFormattingEmpty(t *testing.T) { + report := newLocalStatusReport("/tmp/cache", local.Stats{}, 100, 25) + + actual := renderLocalStatusText(report) + + assert.Contains(t, actual, "Cache entries: 0") + assert.Contains(t, actual, "Cache size: 0B (0 bytes)") + assert.Contains(t, actual, "Oldest entry: n/a") + assert.Contains(t, actual, "Newest entry: n/a") +} + +func TestStatusJSONCompact(t *testing.T) { + oldest := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) + newest := time.Date(2026, time.January, 3, 4, 5, 6, 0, time.UTC) + report := newLocalStatusReport("/tmp/cache", local.Stats{ + Entries: 3, + Bytes: 1024, + Oldest: &oldest, + Newest: &newest, + }, 10*1024, 4*1024) + + actual, err := renderLocalStatusJSON(report) + require.NoError(t, err) + assert.True(t, strings.HasSuffix(actual, "\n")) + + line := strings.TrimSuffix(actual, "\n") + assert.NotContains(t, line, "\n") + + var got jsonLocalStatus + err = json.Unmarshal([]byte(line), &got) + require.NoError(t, err) + + assert.Equal(t, "/tmp/cache", got.Directory) + assert.EqualValues(t, 3, got.Cache.Entries) + assert.EqualValues(t, 1024, got.Cache.Bytes) + assert.NotEmpty(t, got.Cache.Human) + require.NotNil(t, got.Cache.Oldest) + require.NotNil(t, got.Cache.Newest) + assert.Equal(t, oldest.Local().Format(time.RFC3339), *got.Cache.Oldest) + assert.Equal(t, newest.Local().Format(time.RFC3339), *got.Cache.Newest) + assert.EqualValues(t, 10*1024, got.Disk.TotalBytes) + assert.EqualValues(t, 6*1024, got.Disk.UsedBytes) + assert.EqualValues(t, 4*1024, got.Disk.FreeBytes) + assert.EqualValues(t, 60, got.Disk.UsedPercent) + assert.EqualValues(t, 40, got.Disk.FreePercent) + assert.EqualValues(t, 10, got.CacheOfTotalPercent) +}