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
25 changes: 25 additions & 0 deletions internal/caches/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ type Cache struct {
l *slog.Logger
}

// Stats describes local cache state.
// Oldest and Newest are action-entry add times. LeastRecentlyUsed and
// MostRecentlyUsed are approximate last-use times.
type Stats struct {
Entries int
Bytes int64
Oldest *time.Time
Newest *time.Time
LeastRecentlyUsed *time.Time
MostRecentlyUsed *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 +80,19 @@ func (c *Cache) TrimForce() (before, freed int64) {
return c.dc.TrimForce(c.cutoff, c.maxSize, c.l)
}

// Stats returns current local cache statistics.
func (c *Cache) Stats() *Stats {
s := c.dc.Stats(c.l)
return &Stats{
Entries: s.Entries,
Bytes: s.Bytes,
Oldest: s.Oldest,
Newest: s.Newest,
LeastRecentlyUsed: s.LeastRecentlyUsed,
MostRecentlyUsed: s.MostRecentlyUsed,
}
}

// check interfaces
var (
_ cache.Cache = (*Cache)(nil)
Expand Down
40 changes: 40 additions & 0 deletions internal/caches/local/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,43 @@ func TestTrimSizePart(t *testing.T) {
shoulda.BeEqual(t, freed, int64(60_023_595))
shoulda.BeLess(t, before-freed, maxSize)
}

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

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

stats := c.Stats()
shoulda.BeEqual(t, stats.Entries, 1219)
shoulda.BeEqual(t, stats.Bytes, int64(109_518_524))
musta.NotBeZero(t, stats.Oldest)
musta.NotBeZero(t, stats.Newest)
musta.NotBeZero(t, stats.LeastRecentlyUsed)
musta.NotBeZero(t, stats.MostRecentlyUsed)
shoulda.BeEqual(t, stats.Oldest.UnixNano(), int64(1_763_399_577_524_486_000))
shoulda.BeEqual(t, stats.Newest.UnixNano(), int64(1_763_399_587_284_280_000))
shoulda.BeEqual(t, stats.LeastRecentlyUsed.UnixNano(), int64(1_763_399_577_524_467_000))
shoulda.BeEqual(t, stats.MostRecentlyUsed.UnixNano(), int64(1_763_399_587_284_400_000))
shoulda.CompareLess(t, *stats.Oldest, *stats.Newest, time.Time.Compare)
shoulda.CompareLess(t, *stats.LeastRecentlyUsed, *stats.MostRecentlyUsed, time.Time.Compare)
}

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

c, err := New(localtest.Setup(t), nil, new(int64(0)), logger(t))
musta.NoError(t, err)

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

stats := c.Stats()
shoulda.BeEqual(t, stats.Entries, 0)
shoulda.BeEqual(t, stats.Bytes, int64(0))
shoulda.BeZero(t, stats.Oldest)
shoulda.BeZero(t, stats.Newest)
shoulda.BeZero(t, stats.LeastRecentlyUsed)
shoulda.BeZero(t, stats.MostRecentlyUsed)
}
140 changes: 140 additions & 0 deletions internal/commands/local_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package commands

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

"github.com/AlekSi/hardcache/internal/caches/local"
"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"`
Oldest *string `json:"oldest"`
Newest *string `json:"newest"`
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.Stats()
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 *local.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.Oldest = formatTime(stats.Oldest)
res.Cache.Newest = formatTime(stats.Newest)
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)
Oldest entry: %s
Newest entry: %s
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.Oldest),
formatTime(s.Cache.Newest),
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,
)
}
88 changes: 88 additions & 0 deletions internal/commands/local_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package commands

import (
"encoding/json"
"log/slog"
"strings"
"testing"
"time"

"github.com/AlekSi/shoulda"
"github.com/AlekSi/shoulda/musta"

"github.com/AlekSi/hardcache/internal/caches/local"
"github.com/AlekSi/hardcache/internal/caches/local/localtest"
)

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

dir := localtest.Setup(t)
oldest := time.Date(2025, time.November, 17, 17, 12, 57, 524467000, time.UTC).Local().Format(time.RFC3339)
newest := time.Date(2025, time.November, 17, 17, 13, 7, 284400000, time.UTC).Local().Format(time.RFC3339)

t.Run("text", func(t *testing.T) {
var output strings.Builder
musta.NoError(t, LocalStatus(&LocalStatusOpts{Dir: dir}, &output, slog.Default()))

actual := output.String()
shoulda.SatisfyWith(t, actual, "Directory: "+dir, strings.Contains)
shoulda.SatisfyWith(t, actual, "Cache entries: 1219", strings.Contains)
shoulda.SatisfyWith(t, actual, "Cache size: 109MB (109518524 bytes)", strings.Contains)
shoulda.SatisfyWith(t, actual, "Oldest entry: "+oldest, strings.Contains)
shoulda.SatisfyWith(t, actual, "Newest entry: "+newest, strings.Contains)
shoulda.SatisfyWith(t, actual, "Least recently used: "+oldest, strings.Contains)
shoulda.SatisfyWith(t, actual, "Most recently used: "+newest, strings.Contains)
shoulda.SatisfyWith(t, actual, "Disk total: ", strings.Contains)
shoulda.SatisfyWith(t, actual, "Disk free: ", strings.Contains)
})

t.Run("JSON", func(t *testing.T) {
var output strings.Builder
musta.NoError(t, LocalStatus(&LocalStatusOpts{Dir: dir, JSON: true}, &output, slog.Default()))

actual := output.String()
shoulda.SatisfyWith(t, actual, "\n", strings.HasSuffix)
shoulda.BeEqual(t, strings.Count(actual, "\n"), 1)

var got localStatusOutput
musta.NoError(t, json.Unmarshal([]byte(actual), &got))
shoulda.BeEqual(t, got.Directory, dir)
shoulda.BeEqual(t, got.Cache.Entries, 1219)
shoulda.BeEqual(t, got.Cache.Bytes, int64(109_518_524))
shoulda.BeEqual(t, got.Cache.Human, "109MB")
musta.NotBeZero(t, got.Cache.Oldest)
musta.NotBeZero(t, got.Cache.Newest)
musta.NotBeZero(t, got.Cache.LeastRecentlyUsed)
musta.NotBeZero(t, got.Cache.MostRecentlyUsed)
shoulda.BeEqual(t, *got.Cache.Oldest, oldest)
shoulda.BeEqual(t, *got.Cache.Newest, newest)
shoulda.BeEqual(t, *got.Cache.LeastRecentlyUsed, oldest)
shoulda.BeEqual(t, *got.Cache.MostRecentlyUsed, newest)
shoulda.BeGreater(t, got.Disk.TotalBytes, int64(0))
shoulda.BeEqual(t, got.Disk.UsedBytes+got.Disk.FreeBytes, got.Disk.TotalBytes)
})
}

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

dir := localtest.Setup(t)
c, err := local.New(dir, nil, new(int64(0)), slog.Default())
musta.NoError(t, err)

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

var output strings.Builder
musta.NoError(t, LocalStatus(&LocalStatusOpts{Dir: dir}, &output, slog.Default()))

actual := output.String()
shoulda.SatisfyWith(t, actual, "Cache entries: 0", strings.Contains)
shoulda.SatisfyWith(t, actual, "Cache size: 0B (0 bytes)", strings.Contains)
shoulda.SatisfyWith(t, actual, "Oldest entry: n/a", strings.Contains)
shoulda.SatisfyWith(t, actual, "Newest entry: n/a", strings.Contains)
shoulda.SatisfyWith(t, actual, "Least recently used: n/a", strings.Contains)
shoulda.SatisfyWith(t, actual, "Most recently used: n/a", strings.Contains)
}
Loading