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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ test:
run:
go build -race -o bin/
bin/hardcache --help
bin/hardcache local status
mkdir -p tmp/cache
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 All @@ -31,6 +32,20 @@ It can be overridden with `--dir` flag:
hardcache local --dir=/tmp/cache ...
```

#### Status

Display current cache and disk usage stats:

```
hardcache local status
```

Use compact JSON output for scripting:

```
hardcache local status --json
```

#### Manual trimming

Go standard build cache does not support [disabling trimming](https://github.com/golang/go/issues/69565),
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ godebug (

require (
github.com/AlekSi/lazyerrors v0.6.0
github.com/AlekSi/shoulda v0.0.0-20260822211907-a816281ba9e8
github.com/AlekSi/shoulda v0.0.1
github.com/alecthomas/kong v1.16.1
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b
github.com/xhit/go-str2duration/v2 v2.1.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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/AlekSi/shoulda v0.0.0-20260822211907-a816281ba9e8 h1:PtLNw2ECAPhUCCJvy+ASrZgDaFV5XKh184qJ/vx0CXs=
github.com/AlekSi/shoulda v0.0.0-20260822211907-a816281ba9e8/go.mod h1:2BlJHePkF/aGLNg+T2Zqoa6AzrXFB11MQpFxq4TBK2g=
github.com/AlekSi/shoulda v0.0.1 h1:7wdUHXfzY0OLTVx0DJAk5uxb+jcg/a5Nrfo9lg2sT5I=
github.com/AlekSi/shoulda v0.0.1/go.mod h1:2BlJHePkF/aGLNg+T2Zqoa6AzrXFB11MQpFxq4TBK2g=
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.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E=
Expand Down
3 changes: 2 additions & 1 deletion internal/caches/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ func (c *Cache) FuzzDir() string {
// TrimForce removes cache entries (starting from least recently used),
// enforcing both cutoff date and max cache size, if set.
// It ignores the last trim time, but updates it.
func (c *Cache) TrimForce() (before, freed int64) {
// It returns statistics derived from the same cache scan used for trimming.
func (c *Cache) TrimForce() (before, freed int64, stats *cache.Stats) {
return c.dc.TrimForce(c.cutoff, c.maxSize, c.l)
}

Expand Down
100 changes: 91 additions & 9 deletions internal/caches/local/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/hex"
"log/slog"
"math"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -98,12 +100,27 @@ func TestCache(t *testing.T) {
func TestTrimNoop(t *testing.T) {
t.Parallel()

c, err := New(localtest.Setup(t), nil, nil, logger(t))
dir := localtest.Setup(t)
trimPath := filepath.Join(dir, "trim.txt")
trimBefore, err := os.ReadFile(trimPath)
musta.NoError(t, err)

c, err := New(dir, nil, nil, logger(t))
musta.NoError(t, err)

before, freed := c.TrimForce()
before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, -1)
shoulda.BeEqual(t, freed, 0)
trimAfter, err := os.ReadFile(trimPath)
musta.NoError(t, err)
shoulda.BeEqual(t, string(trimAfter), string(trimBefore))

shoulda.BeDeepEqual(t, stats, &cache.Stats{
Entries: 1219,
Bytes: 109_518_524,
LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local()),
MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()),
})
}

func TestTrimCutoffNone(t *testing.T) {
Expand All @@ -112,9 +129,16 @@ func TestTrimCutoffNone(t *testing.T) {
c, err := New(localtest.Setup(t), new(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)), nil, logger(t))
musta.NoError(t, err)

before, freed := c.TrimForce()
before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(0))

shoulda.BeDeepEqual(t, stats, &cache.Stats{
Entries: 1219,
Bytes: 109_518_524,
LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local()),
MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()),
})
}

func TestTrimCutoffAll(t *testing.T) {
Expand All @@ -123,9 +147,11 @@ func TestTrimCutoffAll(t *testing.T) {
c, err := New(localtest.Setup(t), new(time.Date(2999, time.January, 1, 0, 0, 0, 0, time.UTC)), nil, logger(t))
musta.NoError(t, err)

before, freed := c.TrimForce()
before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(109_518_524))

shoulda.BeDeepEqual(t, stats, &cache.Stats{})
}

func TestTrimCutoffPart(t *testing.T) {
Expand All @@ -134,9 +160,16 @@ func TestTrimCutoffPart(t *testing.T) {
c, err := New(localtest.Setup(t), new(time.Date(2025, time.November, 17, 17, 13, 0, 0, time.UTC)), nil, logger(t))
musta.NoError(t, err)

before, freed := c.TrimForce()
before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(3_975_344))

shoulda.BeDeepEqual(t, stats, &cache.Stats{
Entries: 794,
Bytes: 105_543_180,
LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 0, 198000, time.UTC).Local()),
MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()),
})
}

func TestTrimSizeNone(t *testing.T) {
Expand All @@ -145,9 +178,16 @@ func TestTrimSizeNone(t *testing.T) {
c, err := New(localtest.Setup(t), nil, new(int64(math.MaxInt64)), logger(t))
musta.NoError(t, err)

before, freed := c.TrimForce()
before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(0))

shoulda.BeDeepEqual(t, stats, &cache.Stats{
Entries: 1219,
Bytes: 109_518_524,
LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local()),
MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()),
})
}

func TestTrimSizeAll(t *testing.T) {
Expand All @@ -156,9 +196,11 @@ func TestTrimSizeAll(t *testing.T) {
c, err := New(localtest.Setup(t), nil, new(int64(0)), logger(t))
musta.NoError(t, err)

before, freed := c.TrimForce()
before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(109_518_524))

shoulda.BeDeepEqual(t, stats, &cache.Stats{})
}

func TestTrimSizePart(t *testing.T) {
Expand All @@ -168,8 +210,48 @@ func TestTrimSizePart(t *testing.T) {
c, err := New(localtest.Setup(t), nil, new(maxSize), logger(t))
musta.NoError(t, err)

before, freed := c.TrimForce()
before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(60_023_595))
shoulda.BeLess(t, before-freed, maxSize)

shoulda.BeDeepEqual(t, stats, &cache.Stats{
Entries: 413,
Bytes: 49_494_929,
LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 2, 195332000, time.UTC).Local()),
MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()),
})
}

func TestTrimCutoffAndSize(t *testing.T) {
t.Parallel()

cutoff := new(time.Date(2025, time.November, 17, 17, 13, 0, 0, time.UTC))

t.Run("cutoff is sufficient", func(t *testing.T) {
maxSize := int64(106_000_000)
c, err := New(localtest.Setup(t), cutoff, &maxSize, logger(t))
musta.NoError(t, err)

before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(3_975_344))
shoulda.BeEqual(t, stats.Bytes, int64(105_543_180))
shoulda.BeEqual(t, stats.Entries, 794)
})

t.Run("size trimming is also needed", func(t *testing.T) {
maxSize := int64(50_000_000)
c, err := New(localtest.Setup(t), cutoff, &maxSize, logger(t))
musta.NoError(t, err)

before, freed, stats := c.TrimForce()
shoulda.BeEqual(t, before, int64(109_518_524))
shoulda.BeEqual(t, freed, int64(60_023_595))
shoulda.BeDeepEqual(t, stats, &cache.Stats{
Entries: 413,
Bytes: 49_494_929,
LeastRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 2, 195332000, time.UTC).Local()),
MostRecentlyUsed: new(time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local()),
})
})
}
134 changes: 134 additions & 0 deletions internal/commands/local_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package commands

import (
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"time"

"github.com/AlekSi/hardcache/internal/caches/local"
"github.com/AlekSi/hardcache/internal/go/cache"
"github.com/AlekSi/hardcache/internal/unit"
)

type localStatusOutput struct {
Directory string `json:"directory"`
Cache struct {
Entries int `json:"entries"`
Bytes int64 `json:"bytes"`
Human string `json:"human"`
LeastRecentlyUsed *string `json:"least_recently_used"`
MostRecentlyUsed *string `json:"most_recently_used"`
} `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"`
}

// LocalStatusOpts contains flag values for [LocalStatus].
type LocalStatusOpts struct {
Dir string
JSON bool
}

// LocalStatus writes local cache and disk usage statistics to out.
func LocalStatus(opts *LocalStatusOpts, out io.Writer, l *slog.Logger) error {
c, err := local.New(opts.Dir, nil, nil, l)
if err != nil {
return err
}

_, _, stats := c.TrimForce()
total, free, err := local.DiskInfo(opts.Dir)
if err != nil {
return err
}

output := newLocalStatusOutput(opts.Dir, stats, total, free)
if opts.JSON {
return json.NewEncoder(out).Encode(output)
}

_, err = fmt.Fprint(out, output)
return err
}

func newLocalStatusOutput(dir string, stats *cache.Stats, total, free int64) localStatusOutput {
used := max(total-free, 0)
percent := func(value int64) float64 {
if total <= 0 {
return 0
}

return math.Round(float64(value)/float64(total)*10_000) / 100
}

res := localStatusOutput{
Directory: dir,
CacheOfTotalPercent: percent(stats.Bytes),
}
res.Cache.Entries = stats.Entries
res.Cache.Bytes = stats.Bytes
res.Cache.Human = unit.Bytes(stats.Bytes).String()
formatTime := func(t *time.Time) *string {
if t == nil {
return nil
}

return new(t.Local().Format(time.RFC3339))
}
res.Cache.LeastRecentlyUsed = formatTime(stats.LeastRecentlyUsed)
res.Cache.MostRecentlyUsed = formatTime(stats.MostRecentlyUsed)
res.Disk.TotalBytes = total
res.Disk.TotalHuman = unit.Bytes(total).String()
res.Disk.UsedBytes = used
res.Disk.UsedHuman = unit.Bytes(used).String()
res.Disk.UsedPercent = percent(used)
res.Disk.FreeBytes = free
res.Disk.FreeHuman = unit.Bytes(free).String()
res.Disk.FreePercent = percent(free)

return res
}

func (s localStatusOutput) String() string {
formatTime := func(t *string) string {
if t == nil {
return "n/a"
}

return *t
}

return fmt.Sprintf(
`Directory: %s
Cache entries: %d
Cache size: %s (%d bytes)
Least recently used: %s
Most recently used: %s
Disk total: %s (%d bytes)
Disk used: %s (%d bytes) (%.2f%%)
Disk free: %s (%d bytes) (%.2f%%)
Cache of total disk: %.2f%%
`,
s.Directory,
s.Cache.Entries,
s.Cache.Human, s.Cache.Bytes,
formatTime(s.Cache.LeastRecentlyUsed),
formatTime(s.Cache.MostRecentlyUsed),
s.Disk.TotalHuman, s.Disk.TotalBytes,
s.Disk.UsedHuman, s.Disk.UsedBytes, s.Disk.UsedPercent,
s.Disk.FreeHuman, s.Disk.FreeBytes, s.Disk.FreePercent,
s.CacheOfTotalPercent,
)
}
Loading