From c6c48bf1d2ab76a95a8e694a09acd0a41b985013 Mon Sep 17 00:00:00 2001 From: Josh Friend Date: Tue, 17 Mar 2026 14:30:31 -0400 Subject: [PATCH] feat: add LFS snapshot support to git strategy When lfs-snapshot-enabled is true, cachew generates a separate LFS object snapshot (lfs-snapshot.tar.zst) alongside the regular git snapshot. This archives .git/lfs/objects/ after running git lfs fetch, and serves it at GET /git/{repo}/lfs-snapshot.tar.zst. Adds snapshot.CreateSubdir for archiving a named subdirectory with its path prefix preserved in the tar. LFS snapshot jobs are scheduled on both startup discovery and first clone of new repos. --- internal/gitclone/command.go | 4 +- internal/gitclone/command_test.go | 6 +- internal/gitclone/manager.go | 7 +- internal/snapshot/snapshot.go | 37 ++++- internal/strategy/git/git.go | 15 ++ internal/strategy/git/snapshot.go | 258 +++++++++++++++++++++++------- 6 files changed, 256 insertions(+), 71 deletions(-) diff --git a/internal/gitclone/command.go b/internal/gitclone/command.go index d4728c0..891b231 100644 --- a/internal/gitclone/command.go +++ b/internal/gitclone/command.go @@ -11,7 +11,9 @@ import ( "github.com/alecthomas/errors" ) -func (r *Repository) gitCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { +// GitCommand returns a git subprocess configured with repository-scoped +// authentication and any per-URL git config overrides disabled. +func (r *Repository) GitCommand(ctx context.Context, args ...string) (*exec.Cmd, error) { repoURL := r.upstreamURL var token string if r.credentialProvider != nil && strings.Contains(repoURL, "github.com") { diff --git a/internal/gitclone/command_test.go b/internal/gitclone/command_test.go index 4f9465a..6a4f21b 100644 --- a/internal/gitclone/command_test.go +++ b/internal/gitclone/command_test.go @@ -51,7 +51,7 @@ func TestGitCommand(t *testing.T) { credentialProvider: nil, } - cmd, err := repo.gitCommand(ctx, "version") + cmd, err := repo.GitCommand(ctx, "version") assert.NoError(t, err) assert.NotZero(t, cmd) @@ -70,7 +70,7 @@ func TestGitCommandWithEmptyURL(t *testing.T) { credentialProvider: nil, } - cmd, err := repo.gitCommand(ctx, "version") + cmd, err := repo.GitCommand(ctx, "version") assert.NoError(t, err) assert.NotZero(t, cmd) @@ -124,7 +124,7 @@ func TestGitCommandWithCredentialProvider(t *testing.T) { }, } - cmd, err := repo.gitCommand(ctx, "version") + cmd, err := repo.GitCommand(ctx, "version") assert.NoError(t, err) assert.NotZero(t, cmd) diff --git a/internal/gitclone/manager.go b/internal/gitclone/manager.go index 576904b..0f09d53 100644 --- a/internal/gitclone/manager.go +++ b/internal/gitclone/manager.go @@ -502,7 +502,7 @@ func (r *Repository) executeClone(ctx context.Context) error { r.upstreamURL, r.path, } - cmd, err := r.gitCommand(cloneCtx, args...) + cmd, err := r.GitCommand(cloneCtx, args...) if err != nil { return errors.Wrap(err, "create git command") } @@ -583,8 +583,7 @@ func (r *Repository) fetchInternal(ctx context.Context, timeout time.Duration, e } args = append(args, "fetch", "--prune", "--prune-tags") - // #nosec G204 - r.path is controlled by us - cmd, err := r.gitCommand(fetchCtx, args...) + cmd, err := r.GitCommand(fetchCtx, args...) if err != nil { return errors.Wrap(err, "create git command") } @@ -682,7 +681,7 @@ func (r *Repository) GetLocalRefs(ctx context.Context) (map[string]string, error func (r *Repository) GetUpstreamRefs(ctx context.Context) (map[string]string, error) { // #nosec G204 - r.upstreamURL is controlled by us - cmd, err := r.gitCommand(ctx, "ls-remote", r.upstreamURL) + cmd, err := r.GitCommand(ctx, "ls-remote", r.upstreamURL) if err != nil { return nil, errors.Wrap(err, "create git command") } diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go index 8486a3e..7f69b8c 100644 --- a/internal/snapshot/snapshot.go +++ b/internal/snapshot/snapshot.go @@ -25,31 +25,54 @@ import ( // Exclude patterns use tar's --exclude syntax. // threads controls zstd parallelism; 0 uses all available CPU cores. func Create(ctx context.Context, remote cache.Cache, key cache.Key, directory string, ttl time.Duration, excludePatterns []string, threads int) error { + return CreatePaths(ctx, remote, key, directory, filepath.Base(directory), []string{"."}, ttl, excludePatterns, threads) +} + +// CreatePaths archives named paths within baseDir using tar with zstd compression, +// then uploads the resulting archive to the cache. +// +// The archive preserves all file permissions, ownership, and symlinks. +// Each entry in includePaths is archived relative to baseDir and must exist. +// This allows callers to archive either an entire directory with "." or a +// specific subtree such as "lfs" while preserving that relative path prefix. +// Exclude patterns use tar's --exclude syntax. +// threads controls zstd parallelism; 0 uses all available CPU cores. +func CreatePaths(ctx context.Context, remote cache.Cache, key cache.Key, baseDir, archiveName string, includePaths []string, ttl time.Duration, excludePatterns []string, threads int) error { if threads <= 0 { threads = runtime.NumCPU() } - // Verify directory exists - if info, err := os.Stat(directory); err != nil { - return errors.Wrap(err, "failed to stat directory") + if len(includePaths) == 0 { + return errors.New("includePaths must not be empty") + } + + if info, err := os.Stat(baseDir); err != nil { + return errors.Wrap(err, "failed to stat base directory") } else if !info.IsDir() { - return errors.Errorf("not a directory: %s", directory) + return errors.Errorf("not a directory: %s", baseDir) + } + for _, path := range includePaths { + targetPath := filepath.Join(baseDir, path) + if _, err := os.Stat(targetPath); err != nil { + return errors.Wrapf(err, "failed to stat include path %q", path) + } } headers := make(http.Header) headers.Set("Content-Type", "application/zstd") - headers.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(directory)+".tar.zst")) + headers.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", archiveName+".tar.zst")) wc, err := remote.Create(ctx, key, headers, ttl) if err != nil { return errors.Wrap(err, "failed to create object") } - tarArgs := []string{"-cpf", "-", "-C", directory} + tarArgs := []string{"-cpf", "-", "-C", baseDir} for _, pattern := range excludePatterns { tarArgs = append(tarArgs, "--exclude", pattern) } - tarArgs = append(tarArgs, ".") + tarArgs = append(tarArgs, "--") + tarArgs = append(tarArgs, includePaths...) tarCmd := exec.CommandContext(ctx, "tar", tarArgs...) zstdCmd := exec.CommandContext(ctx, "zstd", "-c", fmt.Sprintf("-T%d", threads)) //nolint:gosec // threads is a validated integer, not user input diff --git a/internal/strategy/git/git.go b/internal/strategy/git/git.go index c153d67..1799657 100644 --- a/internal/strategy/git/git.go +++ b/internal/strategy/git/git.go @@ -38,6 +38,7 @@ type Config struct { SnapshotInterval time.Duration `hcl:"snapshot-interval,optional" help:"How often to generate tar.zstd workstation snapshots. 0 disables snapshots." default:"0"` MirrorSnapshotInterval time.Duration `hcl:"mirror-snapshot-interval,optional" help:"How often to generate mirror snapshots for pod bootstrap. 0 uses snapshot-interval. Defaults to 2h." default:"2h"` RepackInterval time.Duration `hcl:"repack-interval,optional" help:"How often to run full repack. 0 disables." default:"0"` + LFSSnapshotEnabled bool `hcl:"lfs-snapshot-enabled,optional" help:"When true, also generate a separate LFS object snapshot at /git/{repo}/lfs-snapshot.tar.zst on each snapshot interval. Requires git-lfs and a configured GitHub App." default:"false"` // ServerURL is embedded as remote.origin.url in snapshots so git pull goes through cachew. ServerURL string `hcl:"server-url,optional" help:"Base URL of this cachew instance, embedded in snapshot remote URLs." default:"${CACHEW_URL}"` ZstdThreads int `hcl:"zstd-threads,optional" help:"Threads for zstd compression/decompression (0 = all CPU cores)." default:"0"` @@ -151,6 +152,9 @@ func New( if s.config.SnapshotInterval > 0 { s.scheduleSnapshotJobs(repo) + if s.config.LFSSnapshotEnabled { + s.scheduleLFSSnapshotJobs(repo) + } } if s.config.RepackInterval > 0 { s.scheduleRepackJobs(repo) @@ -219,6 +223,11 @@ func (s *Strategy) handleRequest(w http.ResponseWriter, r *http.Request) { return } + if strings.HasSuffix(pathValue, "/lfs-snapshot.tar.zst") { + s.handleLFSSnapshotRequest(w, r, host, pathValue) + return + } + service := r.URL.Query().Get("service") isReceivePack := service == "git-receive-pack" || strings.HasSuffix(pathValue, "/git-receive-pack") @@ -497,6 +506,9 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) { if s.config.SnapshotInterval > 0 { s.scheduleSnapshotJobs(repo) + if s.config.LFSSnapshotEnabled { + s.scheduleLFSSnapshotJobs(repo) + } } if s.config.RepackInterval > 0 { s.scheduleRepackJobs(repo) @@ -524,6 +536,9 @@ func (s *Strategy) startClone(ctx context.Context, repo *gitclone.Repository) { if s.config.SnapshotInterval > 0 { s.scheduleSnapshotJobs(repo) + if s.config.LFSSnapshotEnabled { + s.scheduleLFSSnapshotJobs(repo) + } } if s.config.RepackInterval > 0 { s.scheduleRepackJobs(repo) diff --git a/internal/strategy/git/snapshot.go b/internal/strategy/git/snapshot.go index 4690ac0..1524425 100644 --- a/internal/strategy/git/snapshot.go +++ b/internal/strategy/git/snapshot.go @@ -35,6 +35,10 @@ func mirrorSnapshotCacheKey(upstreamURL string) cache.Key { return cache.NewKey(upstreamURL + ".mirror-snapshot") } +func lfsSnapshotCacheKey(upstreamURL string) cache.Key { + return cache.NewKey(upstreamURL + ".lfs-snapshot") +} + // remoteURLForSnapshot returns the URL to embed as remote.origin.url in snapshots. // When a server URL is configured, it returns the cachew URL for the repo so that // git pull goes through cachew. Otherwise it falls back to the upstream URL. @@ -72,45 +76,53 @@ func (s *Strategy) cloneForSnapshot(ctx context.Context, repo *gitclone.Reposito return nil } -func (s *Strategy) generateAndUploadSnapshot(ctx context.Context, repo *gitclone.Repository) error { +func (s *Strategy) withSnapshotClone(ctx context.Context, repo *gitclone.Repository, suffix string, fn func(workDir string) error) error { logger := logging.FromContext(ctx) - upstream := repo.UpstreamURL() - - logger.InfoContext(ctx, "Snapshot generation started", "upstream", upstream) - - mu := s.snapshotMutexFor(upstream) - mu.Lock() - defer mu.Unlock() - mirrorRoot := s.cloneManager.Config().MirrorRoot - snapshotDir, err := snapshotDirForURL(mirrorRoot, upstream) + workDir, err := snapshotDirForURL(mirrorRoot, repo.UpstreamURL()) if err != nil { return err } + workDir += suffix // Clean any previous snapshot working directory. - if err := os.RemoveAll(snapshotDir); err != nil { //nolint:gosec // snapshotDir is derived from controlled mirrorRoot + upstream URL - return errors.Wrap(err, "remove previous snapshot dir") + if err := os.RemoveAll(workDir); err != nil { + return errors.Wrap(err, "remove previous snapshot work dir") } - if err := os.MkdirAll(filepath.Dir(snapshotDir), 0o750); err != nil { //nolint:gosec // snapshotDir is derived from controlled mirrorRoot + upstream URL - return errors.Wrap(err, "create snapshot parent dir") + if err := os.MkdirAll(filepath.Dir(workDir), 0o750); err != nil { + return errors.Wrap(err, "create snapshot work dir parent") } - if err := s.cloneForSnapshot(ctx, repo, snapshotDir); err != nil { - _ = os.RemoveAll(snapshotDir) //nolint:gosec // snapshotDir is derived from controlled mirrorRoot + upstream URL + if err := s.cloneForSnapshot(ctx, repo, workDir); err != nil { + _ = os.RemoveAll(workDir) return err } - cacheKey := snapshotCacheKey(upstream) - excludePatterns := []string{"*.lock"} + // Always clean up the snapshot working directory. + defer func() { + if rmErr := os.RemoveAll(workDir); rmErr != nil { + logger.WarnContext(ctx, "Failed to clean up snapshot work dir", "work_dir", workDir, "error", rmErr) + } + }() + + return fn(workDir) +} - err = snapshot.Create(ctx, s.cache, cacheKey, snapshotDir, 0, excludePatterns, s.config.ZstdThreads) +func (s *Strategy) generateAndUploadSnapshot(ctx context.Context, repo *gitclone.Repository) error { + logger := logging.FromContext(ctx) + upstream := repo.UpstreamURL() - // Always clean up the snapshot working directory. - if rmErr := os.RemoveAll(snapshotDir); rmErr != nil { //nolint:gosec // snapshotDir is derived from controlled mirrorRoot + upstream URL - logger.WarnContext(ctx, "Failed to clean up snapshot dir", "error", rmErr) - } - if err != nil { + logger.InfoContext(ctx, "Snapshot generation started", "upstream", upstream) + + mu := s.snapshotMutexFor(upstream) + mu.Lock() + defer mu.Unlock() + + cacheKey := snapshotCacheKey(upstream) + excludePatterns := []string{"*.lock"} + if err := s.withSnapshotClone(ctx, repo, "", func(workDir string) error { + return snapshot.Create(ctx, s.cache, cacheKey, workDir, 0, excludePatterns, s.config.ZstdThreads) + }); err != nil { return errors.Wrap(err, "create snapshot") } @@ -173,38 +185,10 @@ func (s *Strategy) handleSnapshotRequest(w http.ResponseWriter, r *http.Request, ctx := r.Context() logger := logging.FromContext(ctx) - repoPath := ExtractRepoPath(strings.TrimSuffix(pathValue, "/snapshot.tar.zst")) - upstreamURL := "https://" + host + "/" + repoPath - - // Ensure the local mirror is ready and up to date before considering any - // cached snapshot, so we never serve stale data to workstations. - repo, repoErr := s.cloneManager.GetOrCreate(ctx, upstreamURL) - if repoErr != nil { - logger.ErrorContext(ctx, "Failed to get or create clone", "upstream", upstreamURL, "error", repoErr) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - if cloneErr := s.ensureCloneReady(ctx, repo); cloneErr != nil { - logger.ErrorContext(ctx, "Clone unavailable for snapshot", "upstream", upstreamURL, "error", cloneErr) - http.Error(w, "Repository unavailable", http.StatusServiceUnavailable) + repo, upstreamURL, ok := s.prepareSnapshotArtifactRequest(w, r, host, pathValue, "/snapshot.tar.zst", "snapshot") + if !ok { return } - // Fetch in the background to keep the mirror fresh for subsequent - // git-fetch/git-pull operations through cachew, but don't block - // snapshot serving on it. - refsStale, err := s.checkRefsStale(ctx, repo) - if err != nil { - logger.WarnContext(ctx, "Failed to check upstream refs", "upstream", upstreamURL, "error", err) - } - if refsStale { - logger.InfoContext(ctx, "Refs stale for snapshot request, fetching in background", "upstream", upstreamURL) - go func() { - bgCtx := context.WithoutCancel(ctx) - if err := repo.Fetch(bgCtx); err != nil { - logger.WarnContext(bgCtx, "Background fetch for snapshot failed", "upstream", upstreamURL, "error", err) - } - }() - } cacheKey := snapshotCacheKey(upstreamURL) @@ -230,14 +214,67 @@ func (s *Strategy) handleSnapshotRequest(w http.ResponseWriter, r *http.Request, return } defer reader.Close() + s.streamSnapshotArtifact(ctx, w, reader, headers, upstreamURL, "snapshot") +} + +func (s *Strategy) maybeFetchSnapshotRefsInBackground(ctx context.Context, repo *gitclone.Repository, artifactName string) { + logger := logging.FromContext(ctx) + // Fetch in the background to keep the mirror fresh for subsequent + // git-fetch/git-pull operations through cachew, but don't block + // snapshot serving on it. + refsStale, err := repo.EnsureRefsUpToDate(ctx) + if err != nil { + logger.WarnContext(ctx, "Failed to check upstream refs", "upstream", repo.UpstreamURL(), "artifact", artifactName, "error", err) + return + } + if !refsStale { + return + } + + logger.InfoContext(ctx, "Refs stale for snapshot request, fetching in background", + "upstream", repo.UpstreamURL(), "artifact", artifactName) + go func() { + bgCtx := context.WithoutCancel(ctx) + if err := repo.Fetch(bgCtx); err != nil { + logger.WarnContext(bgCtx, "Background fetch for snapshot failed", + "upstream", repo.UpstreamURL(), "artifact", artifactName, "error", err) + } + }() +} + +func (s *Strategy) prepareSnapshotArtifactRequest(w http.ResponseWriter, r *http.Request, host, pathValue, suffix, artifactName string) (*gitclone.Repository, string, bool) { + ctx := r.Context() + logger := logging.FromContext(ctx) + + repoPath := ExtractRepoPath(strings.TrimSuffix(pathValue, suffix)) + upstreamURL := "https://" + host + "/" + repoPath + + // Ensure the local mirror is ready before considering any cached snapshot. + repo, repoErr := s.cloneManager.GetOrCreate(ctx, upstreamURL) + if repoErr != nil { + logger.ErrorContext(ctx, "Failed to get or create clone", "upstream", upstreamURL, "error", repoErr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return nil, "", false + } + if cloneErr := s.ensureCloneReady(ctx, repo); cloneErr != nil { + logger.ErrorContext(ctx, "Clone unavailable for snapshot", "upstream", upstreamURL, "artifact", artifactName, "error", cloneErr) + http.Error(w, "Repository unavailable", http.StatusServiceUnavailable) + return nil, "", false + } + s.maybeFetchSnapshotRefsInBackground(ctx, repo, artifactName) + return repo, upstreamURL, true +} + +func (s *Strategy) streamSnapshotArtifact(ctx context.Context, w http.ResponseWriter, reader io.ReadCloser, headers http.Header, upstreamURL, artifactName string) { + logger := logging.FromContext(ctx) for key, values := range headers { for _, value := range values { w.Header().Add(key, value) } } - if _, err = io.Copy(w, reader); err != nil { - logger.ErrorContext(ctx, "Failed to stream snapshot", "upstream", upstreamURL, "error", err) + if _, err := io.Copy(w, reader); err != nil { + logger.ErrorContext(ctx, "Failed to stream snapshot artifact", "upstream", upstreamURL, "artifact", artifactName, "error", err) } } @@ -419,3 +456,112 @@ func snapshotSpoolDirForURL(mirrorRoot, upstreamURL string) (string, error) { } return filepath.Join(mirrorRoot, ".snapshot-spools", repoPath), nil } + +// generateAndUploadLFSSnapshot fetches the LFS objects for the repository's default +// branch and archives them as a separate tar.zst served at /git/{repo}/lfs-snapshot.tar.zst. +// +// The archive stores paths relative to .git/ (e.g. ./lfs/objects/xx/yy/sha256) so that +// the client can extract it directly into the repo's .git/ directory. +// +// This is called on the same schedule as generateAndUploadSnapshot so the LFS archive +// stays current with the mirror. It requires the GitHub App token manager to be +// configured so that git-lfs can authenticate with GitHub when fetching objects not +// already present in the mirror's .git/lfs/ store. +func (s *Strategy) generateAndUploadLFSSnapshot(ctx context.Context, repo *gitclone.Repository) error { + logger := logging.FromContext(ctx) + upstream := repo.UpstreamURL() + + // Verify git-lfs is available before doing any work. + if _, err := exec.LookPath("git-lfs"); err != nil { + logger.WarnContext(ctx, "git-lfs not found, skipping LFS snapshot", "upstream", upstream) + return nil + } + + logger.InfoContext(ctx, "LFS snapshot generation started", "upstream", upstream) + + mu := s.snapshotMutexFor(upstream) + mu.Lock() + defer mu.Unlock() + + cacheKey := cache.NewKey(upstream + ".lfs-snapshot") + excludePatterns := []string{"*.lock"} + if err := s.withSnapshotClone(ctx, repo, "-lfs", func(workDir string) error { + // Restore the upstream URL for LFS fetch. git-lfs contacts the LFS server + // at {remote.origin.url}/info/lfs; we must point it at GitHub, not cachew. + // #nosec G204 + if output, err := exec.CommandContext(ctx, "git", "-C", workDir, + "remote", "set-url", "origin", upstream).CombinedOutput(); err != nil { + return errors.Wrapf(err, "restore LFS origin URL: %s", string(output)) + } + + fetchCmd, err := repo.GitCommand(ctx, "-C", workDir, "lfs", "fetch", "origin", "HEAD") + if err != nil { + return errors.Wrap(err, "create git lfs fetch command") + } + if output, err := fetchCmd.CombinedOutput(); err != nil { + return errors.Wrapf(err, "git lfs fetch: %s", string(output)) + } + + lfsDir := filepath.Join(workDir, ".git", "lfs") + if _, err := os.Stat(lfsDir); os.IsNotExist(err) { + logger.InfoContext(ctx, "No LFS objects in repository, skipping LFS snapshot", "upstream", upstream) + return nil + } + + gitDir := filepath.Join(workDir, ".git") + return snapshot.CreatePaths(ctx, s.cache, cacheKey, gitDir, "lfs", []string{"lfs"}, 0, excludePatterns, s.config.ZstdThreads) + }); err != nil { + return errors.Wrap(err, "create LFS snapshot") + } + + logger.InfoContext(ctx, "LFS snapshot generation completed", "upstream", upstream) + return nil +} + +func (s *Strategy) scheduleLFSSnapshotJobs(repo *gitclone.Repository) { + s.scheduler.SubmitPeriodicJob(repo.UpstreamURL(), "lfs-snapshot-periodic", s.config.SnapshotInterval, func(ctx context.Context) error { + return s.generateAndUploadLFSSnapshot(ctx, repo) + }) +} + +func (s *Strategy) handleLFSSnapshotRequest(w http.ResponseWriter, r *http.Request, host, pathValue string) { + ctx := r.Context() + logger := logging.FromContext(ctx) + + repo, upstreamURL, ok := s.prepareSnapshotArtifactRequest(w, r, host, pathValue, "/lfs-snapshot.tar.zst", "lfs-snapshot") + if !ok { + return + } + + cacheKey := lfsSnapshotCacheKey(upstreamURL) + reader, headers, err := s.cache.Open(ctx, cacheKey) + if err != nil && !errors.Is(err, os.ErrNotExist) { + logger.ErrorContext(ctx, "Failed to open LFS snapshot from cache", "upstream", upstreamURL, "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if reader == nil { + logger.InfoContext(ctx, "LFS snapshot cache miss, generating on demand", "upstream", upstreamURL) + if err := s.generateAndUploadLFSSnapshot(ctx, repo); err != nil { + logger.ErrorContext(ctx, "Failed to generate LFS snapshot on demand", "upstream", upstreamURL, "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + reader, headers, err = s.cache.Open(ctx, cacheKey) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "LFS snapshot not found", http.StatusNotFound) + return + } + logger.ErrorContext(ctx, "Failed to open generated LFS snapshot from cache", "upstream", upstreamURL, "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } else { + logger.DebugContext(ctx, "Serving cached LFS snapshot", "upstream", upstreamURL) + } + defer reader.Close() + s.streamSnapshotArtifact(ctx, w, reader, headers, upstreamURL, "lfs-snapshot") +}