Skip to content
Draft
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down
19 changes: 19 additions & 0 deletions internal/caches/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions internal/caches/local/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
31 changes: 31 additions & 0 deletions internal/go/cache/cache_extra.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 31 additions & 19 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@
//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:""`

Expand All @@ -52,26 +60,26 @@
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 {

Check failure on line 64 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on windows-2025

other declaration of maxSize

Check failure on line 64 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on windows-2025

other declaration of maxSize

Check failure on line 64 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on ubuntu-24.04

other declaration of maxSize

Check failure on line 64 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on ubuntu-24.04

other declaration of maxSize

Check failure on line 64 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on macos-26

other declaration of maxSize

Check failure on line 64 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on macos-26

other declaration of maxSize
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
}
Expand All @@ -86,7 +94,7 @@
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
}

Expand All @@ -97,12 +105,12 @@
return fmt.Errorf("--max-size cannot be negative: %d", b)
}

var maxSize *int64

Check failure on line 108 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on windows-2025

maxSize redeclared in this block

Check failure on line 108 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on windows-2025

maxSize redeclared in this block

Check failure on line 108 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on ubuntu-24.04

maxSize redeclared in this block

Check failure on line 108 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on ubuntu-24.04

maxSize redeclared in this block

Check failure on line 108 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on macos-26

maxSize redeclared in this block

Check failure on line 108 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on macos-26

maxSize redeclared in this block
if b > 0 {
maxSize = (*int64)(&b)

Check failure on line 110 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on windows-2025

cannot use (*int64)(&b) (value of type *int64) as string value in assignment

Check failure on line 110 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on windows-2025

cannot use (*int64)(&b) (value of type *int64) as string value in assignment

Check failure on line 110 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on ubuntu-24.04

cannot use (*int64)(&b) (value of type *int64) as string value in assignment

Check failure on line 110 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on ubuntu-24.04

cannot use (*int64)(&b) (value of type *int64) as string value in assignment

Check failure on line 110 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on macos-26

cannot use (*int64)(&b) (value of type *int64) as string value in assignment

Check failure on line 110 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on macos-26

cannot use (*int64)(&b) (value of type *int64) as string value in assignment
}

c, err := local.New(cli.Local.Dir, cutoff, maxSize, l)
c, err := local.New(dir, cutoff, maxSize, l)

Check failure on line 113 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on windows-2025

cannot use maxSize (variable of type string) as *int64 value in argument to local.New

Check failure on line 113 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on windows-2025

cannot use maxSize (variable of type string) as *int64 value in argument to local.New

Check failure on line 113 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on ubuntu-24.04

cannot use maxSize (variable of type string) as *int64 value in argument to local.New

Check failure on line 113 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on ubuntu-24.04

cannot use maxSize (variable of type string) as *int64 value in argument to local.New

Check failure on line 113 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.26.x on macos-26

cannot use maxSize (variable of type string) as *int64 value in argument to local.New

Check failure on line 113 in main.go

View workflow job for this annotation

GitHub Actions / Test 1.25.x on macos-26

cannot use maxSize (variable of type string) as *int64 value in argument to local.New
if err != nil {
return err
}
Expand Down Expand Up @@ -151,17 +159,21 @@
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 {
Expand Down
Loading
Loading