Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ jobs:
run: git config --global core.autocrlf input

- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6

- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go }}

Expand Down
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
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
25 changes: 25 additions & 0 deletions internal/caches/local/local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,28 @@ 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 := musta.NotFail(New(setup(t), nil, nil, logger(t)))(t)

stats := c.Status()
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)
shoulda.CompareLess(t, *stats.Oldest, *stats.Newest, time.Time.Compare)
}

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

c := musta.NotFail(New(t.TempDir(), nil, nil, logger(t)))(t)

stats := c.Status()
shoulda.BeEqual(t, stats.Entries, 0)
shoulda.BeEqual(t, stats.Bytes, int64(0))
shoulda.BeZero(t, stats.Oldest)
shoulda.BeZero(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 @@ 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:""`

Expand All @@ -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, maxSizeValue 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(maxSizeValue, "%") {
var p unit.Percentage
if err := p.UnmarshalText([]byte(cli.Local.MaxSize)); err != nil {
if err := p.UnmarshalText([]byte(maxSizeValue)); 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 @@ 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(maxSizeValue)); err != nil {
return err
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading