From 3e19650df0464fa0ae1d8b47a961949938dea376 Mon Sep 17 00:00:00 2001 From: Josh Friend Date: Fri, 20 Mar 2026 13:19:49 -0400 Subject: [PATCH] feat(git): add observability for snapshot restore and serve paths Adds OTel metrics and structured logging to track how repositories are populated on cachew pods (local/mirror/upstream) and how snapshots are served to workstations (cache/live), including duration, bytes, and throughput. --- .gitignore | 1 + cmd/cachew/main.go | 4 +- internal/snapshot/snapshot.go | 38 +++- internal/snapshot/snapshot_test.go | 18 +- internal/strategy/git/git.go | 31 ++- internal/strategy/git/metrics.go | 262 +++++++++++++++++++++++++ internal/strategy/git/snapshot.go | 23 ++- internal/strategy/git/snapshot_test.go | 10 +- 8 files changed, 358 insertions(+), 29 deletions(-) create mode 100644 internal/strategy/git/metrics.go diff --git a/.gitignore b/.gitignore index 9863767..e37efce 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ block-test.hcl .env .claude/ +.idea/ # Binaries /cachew diff --git a/cmd/cachew/main.go b/cmd/cachew/main.go index 903bcb4..beb68ee 100644 --- a/cmd/cachew/main.go +++ b/cmd/cachew/main.go @@ -200,9 +200,11 @@ type RestoreCmd struct { func (c *RestoreCmd) Run(ctx context.Context, cache cache.Cache) error { fmt.Fprintf(os.Stderr, "Restoring to %s...\n", c.Directory) //nolint:forbidigo namespacedCache := cache.Namespace(c.Namespace) - if err := snapshot.Restore(ctx, namespacedCache, c.Key.Key(), c.Directory, c.ZstdThreads); err != nil { + result, err := snapshot.Restore(ctx, namespacedCache, c.Key.Key(), c.Directory, c.ZstdThreads) + if err != nil { return errors.Wrap(err, "failed to restore snapshot") } + fmt.Fprintf(os.Stderr, "Restored %d bytes in %dms\n", result.BytesRead, result.Duration.Milliseconds()) //nolint:forbidigo fmt.Fprintf(os.Stderr, "Snapshot restored: %s\n", c.Key.String()) //nolint:forbidigo return nil diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 49e88ef..f4a8059 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -159,20 +159,51 @@ func StreamTo(ctx context.Context, w io.Writer, directory string, excludePattern return errors.Join(errs...) } +// RestoreResult contains metadata about a completed restore operation. +type RestoreResult struct { + // BytesRead is the number of compressed bytes read from the cache. + BytesRead int64 + // Duration is the wall-clock time for the restore operation. + Duration time.Duration +} + +// countingReader wraps an io.Reader and counts the number of bytes read. +type countingReader struct { + r io.Reader + n int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += int64(n) + return n, err //nolint:wrapcheck +} + // Restore downloads an archive from the cache and extracts it to a directory. // // The archive is decompressed with zstd and extracted with tar, preserving // all file permissions, ownership, and symlinks. // The operation is fully streaming - no temporary files are created. // threads controls zstd parallelism; 0 uses all available CPU cores. -func Restore(ctx context.Context, remote cache.Cache, key cache.Key, directory string, threads int) error { +func Restore(ctx context.Context, remote cache.Cache, key cache.Key, directory string, threads int) (*RestoreResult, error) { + start := time.Now() + rc, _, err := remote.Open(ctx, key) if err != nil { - return errors.Wrap(err, "failed to open object") + return nil, errors.Wrap(err, "failed to open object") } defer rc.Close() - return Extract(ctx, rc, directory, threads) + cr := &countingReader{r: rc} + + if err := Extract(ctx, cr, directory, threads); err != nil { + return nil, err + } + + return &RestoreResult{ + BytesRead: cr.n, + Duration: time.Since(start), + }, nil } // Extract decompresses a zstd+tar stream into directory, preserving all file @@ -183,6 +214,7 @@ func Extract(ctx context.Context, r io.Reader, directory string, threads int) er threads = runtime.NumCPU() } + // Create target directory if it doesn't exist if err := os.MkdirAll(directory, 0o750); err != nil { return errors.Wrap(err, "failed to create target directory") } diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go index de35831..c68f343 100644 --- a/internal/snapshot/snapshot_test.go +++ b/internal/snapshot/snapshot_test.go @@ -38,7 +38,7 @@ func TestCreateAndRestoreRoundTrip(t *testing.T) { assert.Equal(t, "application/zstd", headers.Get("Content-Type")) dstDir := t.TempDir() - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.NoError(t, err) content1, err := os.ReadFile(filepath.Join(dstDir, "file1.txt")) @@ -75,7 +75,7 @@ func TestCreateWithExcludePatterns(t *testing.T) { assert.NoError(t, err) dstDir := t.TempDir() - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.NoError(t, err) _, err = os.Stat(filepath.Join(dstDir, "include.txt")) @@ -111,7 +111,7 @@ func TestCreateExcludesOnlyGitLockFiles(t *testing.T) { assert.NoError(t, err) dstDir := t.TempDir() - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.NoError(t, err) // Tracked lock files must be present. @@ -152,7 +152,7 @@ func TestCreatePreservesSymlinks(t *testing.T) { assert.NoError(t, err) dstDir := t.TempDir() - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.NoError(t, err) info, err := os.Lstat(filepath.Join(dstDir, "link.txt")) @@ -219,7 +219,7 @@ func TestRestoreNonexistentKey(t *testing.T) { key := cache.Key{1, 2, 3} dstDir := t.TempDir() - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.Error(t, err) } @@ -237,7 +237,7 @@ func TestRestoreCreatesTargetDirectory(t *testing.T) { assert.NoError(t, err) dstDir := filepath.Join(t.TempDir(), "nested", "target") - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.NoError(t, err) content, err := os.ReadFile(filepath.Join(dstDir, "file.txt")) @@ -266,7 +266,7 @@ func TestRestoreContextCancellation(t *testing.T) { cancel() dstDir := t.TempDir() - err = snapshot.Restore(cancelCtx, mem, key, dstDir, 0) + _, err = snapshot.Restore(cancelCtx, mem, key, dstDir, 0) assert.Error(t, err) } @@ -283,7 +283,7 @@ func TestCreateEmptyDirectory(t *testing.T) { assert.NoError(t, err) dstDir := t.TempDir() - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.NoError(t, err) entries, err := os.ReadDir(dstDir) @@ -307,7 +307,7 @@ func TestCreateWithNestedDirectories(t *testing.T) { assert.NoError(t, err) dstDir := t.TempDir() - err = snapshot.Restore(ctx, mem, key, dstDir, 0) + _, err = snapshot.Restore(ctx, mem, key, dstDir, 0) assert.NoError(t, err) content, err := os.ReadFile(filepath.Join(dstDir, "a", "b", "c", "d", "e", "deep.txt")) diff --git a/internal/strategy/git/git.go b/internal/strategy/git/git.go index a457743..505bc5a 100644 --- a/internal/strategy/git/git.go +++ b/internal/strategy/git/git.go @@ -140,6 +140,8 @@ func New( logger.InfoContext(ctx, "Startup fetch completed for existing repo", "upstream", repo.UpstreamURL(), "duration", time.Since(start)) + recordCloneMetrics(ctx, "local", time.Since(start), 0) + postRefs, err := repo.GetLocalRefs(ctx) if err != nil { logger.WarnContext(ctx, "Failed to get post-fetch refs for existing repo", "upstream", repo.UpstreamURL(), @@ -469,8 +471,10 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) { logger.InfoContext(ctx, "Attempting mirror snapshot restore", "upstream", upstream) - if err := s.tryRestoreSnapshot(ctx, repo); err != nil { - logger.InfoContext(ctx, "Mirror snapshot restore failed, falling back to clone", "upstream", upstream, "error", err) + cloneStart := time.Now() + restoreResult, restoreErr := s.tryRestoreSnapshot(ctx, repo) + if restoreErr != nil { + logger.InfoContext(ctx, "Mirror snapshot restore failed, falling back to clone", "upstream", upstream, "error", restoreErr) } else { logger.InfoContext(ctx, "Mirror snapshot restored, fetching to freshen", "upstream", upstream) @@ -500,6 +504,9 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) { logger.InfoContext(ctx, "Post-restore fetch completed, serving", "upstream", upstream) + recordCloneSuccess(ctx, "mirror") + recordCloneMetrics(ctx, "mirror", time.Since(cloneStart), restoreResult.BytesRead) + if s.config.SnapshotInterval > 0 { s.scheduleSnapshotJobs(repo) } @@ -522,11 +529,15 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) { if err != nil { logger.ErrorContext(ctx, "Clone failed", "upstream", upstream, "error", err) + recordCloneFailure(ctx, "upstream", err) return } logger.InfoContext(ctx, "Clone completed", "upstream", upstream, "path", repo.Path()) + recordCloneSuccess(ctx, "upstream") + recordCloneMetrics(ctx, "upstream", time.Since(cloneStart), 0) + if s.config.SnapshotInterval > 0 { s.scheduleSnapshotJobs(repo) } @@ -539,28 +550,30 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) { // Mirror snapshots are bare repositories that can be extracted and used directly // without any conversion. On failure the repo path is cleaned up so the caller // can fall back to clone. -func (s *Strategy) tryRestoreSnapshot(ctx context.Context, repo *gitclone.Repository) error { +func (s *Strategy) tryRestoreSnapshot(ctx context.Context, repo *gitclone.Repository) (*snapshot.RestoreResult, error) { cacheKey := mirrorSnapshotCacheKey(repo.UpstreamURL()) if err := os.MkdirAll(filepath.Dir(repo.Path()), 0o750); err != nil { - return errors.Wrap(err, "create parent directory for restore") + return nil, errors.Wrap(err, "create parent directory for restore") } logger := logging.FromContext(ctx) - if err := snapshot.Restore(ctx, s.cache, cacheKey, repo.Path(), s.config.ZstdThreads); err != nil { + result, err := snapshot.Restore(ctx, s.cache, cacheKey, repo.Path(), s.config.ZstdThreads) + if err != nil { _ = os.RemoveAll(repo.Path()) - return errors.Wrap(err, "restore mirror snapshot") + return nil, errors.Wrap(err, "restore mirror snapshot") } - logger.InfoContext(ctx, "Mirror snapshot extracted", "upstream", repo.UpstreamURL(), "path", repo.Path()) + logger.InfoContext(ctx, "Mirror snapshot extracted", "upstream", repo.UpstreamURL(), "path", repo.Path(), + "bytes_read", result.BytesRead, "duration_ms", result.Duration.Milliseconds()) if err := repo.MarkRestored(ctx); err != nil { _ = os.RemoveAll(repo.Path()) - return errors.Wrap(err, "mark restored") + return nil, errors.Wrap(err, "mark restored") } logger.InfoContext(ctx, "Repository marked as restored", "upstream", repo.UpstreamURL(), "state", repo.State()) - return nil + return result, nil } func (s *Strategy) maybeBackgroundFetch(repo *gitclone.Repository) { diff --git a/internal/strategy/git/metrics.go b/internal/strategy/git/metrics.go new file mode 100644 index 0000000..401c692 --- /dev/null +++ b/internal/strategy/git/metrics.go @@ -0,0 +1,262 @@ +package git + +import ( + "context" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +var meter = otel.Meter("cachew/git") + +// Clone metrics track how repositories are initially populated on a cachew pod. +// The "source" attribute distinguishes between: +// - "local": already present on local disk from a previous pod lifecycle +// - "mirror": restored from a cached mirror snapshot (e.g. S3) +// - "upstream": full clone from upstream (e.g. GitHub) +// +// Mirror restores also include a "size_class" attribute (small/medium/large/huge) +// to allow segmented analysis without per-repo cardinality. +var ( + cloneTotal metric.Int64Counter + cloneDuration metric.Float64Histogram + cloneBytes metric.Int64Histogram + cloneThroughput metric.Float64Histogram +) + +// Clone outcome counters track success/failure independently from source. +// The "error_class" attribute on failures provides low-cardinality bucketing +// (e.g. "timeout", "auth", "network", "unknown"). +var ( + cloneSuccess metric.Int64Counter + cloneFailure metric.Int64Counter +) + +// Snapshot cache metrics track whether the pre-built snapshot artifact was +// found in the cache layer when a workstation requested it. +var ( + snapshotCacheHit metric.Int64Counter + snapshotCacheMiss metric.Int64Counter +) + +// Snapshot serve metrics track how snapshot.tar.zst requests to workstations +// are fulfilled. The "source" attribute distinguishes between: +// - "cache": served from a pre-built snapshot in the cache layer +// - "live": generated on-the-fly from the local mirror (cache miss) +var ( + snapshotServeTotal metric.Int64Counter + snapshotServeDuration metric.Float64Histogram + snapshotServeBytes metric.Int64Histogram + snapshotServeThroughput metric.Float64Histogram +) + +func init() { + var err error + + cloneTotal, err = meter.Int64Counter("cachew.git.clone.total", + metric.WithDescription("Number of repository clones by source.")) + if err != nil { + panic(err) + } + + cloneDuration, err = meter.Float64Histogram("cachew.git.clone.duration_ms", + metric.WithDescription("Time to clone/restore a repository in milliseconds."), + metric.WithExplicitBucketBoundaries(100, 500, 1000, 5000, 10000, 30000, 60000, 120000, 300000)) + if err != nil { + panic(err) + } + + cloneBytes, err = meter.Int64Histogram("cachew.git.clone.bytes", + metric.WithDescription("Compressed bytes read during mirror snapshot restore."), + metric.WithExplicitBucketBoundaries(1e6, 10e6, 100e6, 500e6, 1e9, 5e9, 10e9, 20e9)) + if err != nil { + panic(err) + } + + // S3 single-stream is ~100 MB/s, parallel range downloads up to ~250 MB/s. + // Local serves can reach 400-500 MB/s. + cloneThroughput, err = meter.Float64Histogram("cachew.git.clone.throughput_mbps", + metric.WithDescription("Effective download throughput in MB/s during mirror snapshot restore."), + metric.WithUnit("MB/s"), + metric.WithExplicitBucketBoundaries(10, 25, 50, 75, 100, 150, 200, 250, 350, 500)) + if err != nil { + panic(err) + } + + cloneSuccess, err = meter.Int64Counter("cachew.git.clone.success", + metric.WithDescription("Number of successful repository clones/restores.")) + if err != nil { + panic(err) + } + + cloneFailure, err = meter.Int64Counter("cachew.git.clone.failure", + metric.WithDescription("Number of failed repository clones/restores, by error class.")) + if err != nil { + panic(err) + } + + snapshotCacheHit, err = meter.Int64Counter("cachew.git.snapshot.cache.hit", + metric.WithDescription("Number of snapshot requests served from cache.")) + if err != nil { + panic(err) + } + + snapshotCacheMiss, err = meter.Int64Counter("cachew.git.snapshot.cache.miss", + metric.WithDescription("Number of snapshot requests that required live generation.")) + if err != nil { + panic(err) + } + + snapshotServeTotal, err = meter.Int64Counter("cachew.git.snapshot.serve.total", + metric.WithDescription("Number of snapshot requests served to workstations, by source (cache, live).")) + if err != nil { + panic(err) + } + + snapshotServeDuration, err = meter.Float64Histogram("cachew.git.snapshot.serve.duration_ms", + metric.WithDescription("Time to serve a snapshot to a workstation in milliseconds."), + metric.WithExplicitBucketBoundaries(100, 500, 1000, 5000, 10000, 30000, 60000, 120000, 300000)) + if err != nil { + panic(err) + } + + snapshotServeBytes, err = meter.Int64Histogram("cachew.git.snapshot.serve.bytes", + metric.WithDescription("Bytes served to a workstation for a snapshot request."), + metric.WithExplicitBucketBoundaries(1e6, 10e6, 100e6, 500e6, 1e9, 5e9, 10e9, 20e9)) + if err != nil { + panic(err) + } + + // Local serves can sustain 400-500 MB/s. Upstream is typically much slower. + snapshotServeThroughput, err = meter.Float64Histogram("cachew.git.snapshot.serve.throughput_mbps", + metric.WithDescription("Effective throughput in MB/s serving a snapshot to a workstation."), + metric.WithUnit("MB/s"), + metric.WithExplicitBucketBoundaries(10, 25, 50, 75, 100, 150, 200, 250, 350, 500)) + if err != nil { + panic(err) + } +} + +// sizeClass returns a low-cardinality bucket label for a byte count. +// +// - "small": < 100 MB +// - "medium": 100 MB – 1 GB +// - "large": 1 GB – 5 GB +// - "huge": > 5 GB +func sizeClass(bytes int64) string { + switch { + case bytes < 100*1e6: + return "small" + case bytes < 1e9: + return "medium" + case bytes < 5*1e9: + return "large" + default: + return "huge" + } +} + +// recordCloneMetrics records clone/restore metrics with the given source label. +// bytesRead should be > 0 only for mirror restores; it is skipped for local/upstream. +func recordCloneMetrics(ctx context.Context, source string, duration time.Duration, bytesRead int64) { + attrs := []attribute.KeyValue{attribute.String("source", source)} + + if bytesRead > 0 { + attrs = append(attrs, attribute.String("size_class", sizeClass(bytesRead))) + } + + opt := metric.WithAttributes(attrs...) + cloneTotal.Add(ctx, 1, opt) + cloneDuration.Record(ctx, float64(duration.Milliseconds()), opt) + + if bytesRead > 0 { + cloneBytes.Record(ctx, bytesRead, opt) + + if secs := duration.Seconds(); secs > 0 { + mbps := float64(bytesRead) / 1e6 / secs + cloneThroughput.Record(ctx, mbps, opt) + } + } +} + +// recordCloneSuccess increments the clone success counter with the given source. +func recordCloneSuccess(ctx context.Context, source string) { + cloneSuccess.Add(ctx, 1, metric.WithAttributes(attribute.String("source", source))) +} + +// recordCloneFailure increments the clone failure counter with an error class. +func recordCloneFailure(ctx context.Context, source string, err error) { + cloneFailure.Add(ctx, 1, metric.WithAttributes( + attribute.String("source", source), + attribute.String("error_class", classifyError(err)), + )) +} + +// classifyError returns a low-cardinality error class for metrics tagging. +func classifyError(err error) string { + if err == nil { + return "none" + } + msg := err.Error() + switch { + case contains(msg, "timeout", "deadline exceeded", "context deadline"): + return "timeout" + case contains(msg, "context canceled"): + return "canceled" + case contains(msg, "authentication", "authorization", "403", "401"): + return "auth" + case contains(msg, "connection refused", "no such host", "network", "dial"): + return "network" + default: + return "unknown" + } +} + +// contains checks if s contains any of the substrings (case-insensitive). +func contains(s string, substrs ...string) bool { + lower := strings.ToLower(s) + for _, sub := range substrs { + if strings.Contains(lower, sub) { + return true + } + } + return false +} + +// recordSnapshotCacheResult records whether a snapshot was found in cache. +func recordSnapshotCacheResult(ctx context.Context, hit bool) { + if hit { + snapshotCacheHit.Add(ctx, 1) + } else { + snapshotCacheMiss.Add(ctx, 1) + } +} + +// recordSnapshotServe records metrics for a snapshot served to a workstation. +// bytesWritten and duration are optional (zero values are skipped). +func recordSnapshotServe(ctx context.Context, source string, duration time.Duration, bytesWritten int64) { + attrs := []attribute.KeyValue{attribute.String("source", source)} + + if bytesWritten > 0 { + attrs = append(attrs, attribute.String("size_class", sizeClass(bytesWritten))) + } + + opt := metric.WithAttributes(attrs...) + snapshotServeTotal.Add(ctx, 1, opt) + + if duration > 0 { + snapshotServeDuration.Record(ctx, float64(duration.Milliseconds()), opt) + } + + if bytesWritten > 0 { + snapshotServeBytes.Record(ctx, bytesWritten, opt) + + if secs := duration.Seconds(); secs > 0 { + mbps := float64(bytesWritten) / 1e6 / secs + snapshotServeThroughput.Record(ctx, mbps, opt) + } + } +} diff --git a/internal/strategy/git/snapshot.go b/internal/strategy/git/snapshot.go index c3ccac3..bb24e17 100644 --- a/internal/strategy/git/snapshot.go +++ b/internal/strategy/git/snapshot.go @@ -21,6 +21,18 @@ import ( "github.com/block/cachew/internal/snapshot" ) +// countingResponseWriter wraps an http.ResponseWriter to track bytes written. +type countingResponseWriter struct { + http.ResponseWriter + bytesWritten int64 +} + +func (cw *countingResponseWriter) Write(b []byte) (int, error) { + n, err := cw.ResponseWriter.Write(b) + cw.bytesWritten += int64(n) + return n, err +} + func snapshotDirForURL(mirrorRoot, upstreamURL string) (string, error) { repoPath, err := gitclone.RepoPathFromURL(upstreamURL) if err != nil { @@ -213,15 +225,22 @@ func (s *Strategy) handleSnapshotRequest(w http.ResponseWriter, r *http.Request, return } + cw := &countingResponseWriter{ResponseWriter: w} + serveStart := time.Now() + if reader == nil { - s.serveSnapshotWithSpool(w, r, repo, upstreamURL) + recordSnapshotCacheResult(ctx, false) + s.serveSnapshotWithSpool(cw, r, repo, upstreamURL) + recordSnapshotServe(ctx, "live", time.Since(serveStart), cw.bytesWritten) return } defer reader.Close() + recordSnapshotCacheResult(ctx, true) - if err := s.serveSnapshotWithBundle(ctx, w, reader, headers, repo, upstreamURL); err != nil { + if err := s.serveSnapshotWithBundle(ctx, cw, reader, headers, repo, upstreamURL); err != nil { logger.ErrorContext(ctx, "Failed to serve snapshot", "upstream", upstreamURL, "error", err) } + recordSnapshotServe(ctx, "cache", time.Since(serveStart), cw.bytesWritten) } func (s *Strategy) handleBundleRequest(w http.ResponseWriter, r *http.Request, host, pathValue string) { diff --git a/internal/strategy/git/snapshot_test.go b/internal/strategy/git/snapshot_test.go index 405ba64..253f166 100644 --- a/internal/strategy/git/snapshot_test.go +++ b/internal/strategy/git/snapshot_test.go @@ -211,7 +211,7 @@ func TestSnapshotGenerationViaLocalClone(t *testing.T) { // Restore the snapshot and verify it is a working (non-bare) checkout. restoreDir := filepath.Join(tmpDir, "restored") - err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) + _, err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) assert.NoError(t, err) // A non-bare clone has a .git directory (not a bare repo). @@ -271,7 +271,7 @@ func TestSnapshotGenerationIncludesTrackedLockFiles(t *testing.T) { cacheKey := cache.NewKey(upstreamURL + ".snapshot") restoreDir := filepath.Join(tmpDir, "restored") - err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) + _, err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) assert.NoError(t, err) data, err := os.ReadFile(filepath.Join(restoreDir, "package-lock.json")) @@ -316,7 +316,7 @@ func TestMirrorSnapshotRestoreDirectly(t *testing.T) { // Restore the mirror snapshot into a new directory. restoreDir := filepath.Join(tmpDir, "restored-mirror") cacheKey := cache.NewKey(upstreamURL + ".mirror-snapshot") - err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) + _, err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) assert.NoError(t, err) // Should be bare already (no .git subdir). @@ -391,7 +391,7 @@ func TestMirrorSnapshotWithMultipleBranches(t *testing.T) { // Restore and verify all branches are present as refs/heads/*. restoreDir := filepath.Join(tmpDir, "restored-mirror") cacheKey := cache.NewKey(upstreamURL + ".mirror-snapshot") - err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) + _, err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) assert.NoError(t, err) cmd := exec.Command("git", "-C", restoreDir, "show-ref", "--heads") @@ -482,7 +482,7 @@ func TestSnapshotRemoteURLUsesServerURL(t *testing.T) { cacheKey := cache.NewKey(upstreamURL + ".snapshot") restoreDir := filepath.Join(tmpDir, "restored") - err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) + _, err = snapshot.Restore(ctx, memCache, cacheKey, restoreDir, 0) assert.NoError(t, err) cmd := exec.Command("git", "-C", restoreDir, "remote", "get-url", "origin")