package housekeeping

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"testing"
	"time"

	"github.com/prometheus/client_golang/prometheus/testutil"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"gitlab.com/gitlab-org/gitaly/v15/internal/backchannel"
	"gitlab.com/gitlab-org/gitaly/v15/internal/command"
	"gitlab.com/gitlab-org/gitaly/v15/internal/git"
	"gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest"
	"gitlab.com/gitlab-org/gitaly/v15/internal/git/localrepo"
	"gitlab.com/gitlab-org/gitaly/v15/internal/git/repository"
	"gitlab.com/gitlab-org/gitaly/v15/internal/git/stats"
	"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config"
	gitalycfgprom "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config/prometheus"
	"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/transaction"
	"gitlab.com/gitlab-org/gitaly/v15/internal/helper/perm"
	"gitlab.com/gitlab-org/gitaly/v15/internal/metadata/featureflag"
	"gitlab.com/gitlab-org/gitaly/v15/internal/testhelper"
	"gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg"
)

type errorInjectingCommandFactory struct {
	git.CommandFactory
	injectedErrors map[string]error
}

func (f errorInjectingCommandFactory) New(
	ctx context.Context,
	repo repository.GitRepo,
	cmd git.Command,
	opts ...git.CmdOpt,
) (*command.Command, error) {
	if injectedErr, ok := f.injectedErrors[cmd.Name]; ok {
		return nil, injectedErr
	}

	return f.CommandFactory.New(ctx, repo, cmd, opts...)
}

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

	ctx := testhelper.Context(t)
	cfg := testcfg.Build(t)

	t.Run("no repacking", func(t *testing.T) {
		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		// Create a loose object to verify it's not getting repacked.
		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"), gittest.WithMessage("a"))

		didRepack, repackObjectsCfg, err := repackIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldRepackObjects: false,
		})
		require.NoError(t, err)
		require.False(t, didRepack)
		require.Equal(t, RepackObjectsConfig{}, repackObjectsCfg)

		requireObjectsState(t, repo, objectsState{
			looseObjects: 2,
		})
	})

	t.Run("incremental repack", func(t *testing.T) {
		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		// Create an object and pack it into a packfile.
		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"), gittest.WithMessage("a"))
		gittest.Exec(t, cfg, "-C", repoPath, "repack", "-Ad")
		// And a second object that is loose. The incremental repack should only pack the
		// loose object.
		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"), gittest.WithMessage("b"))

		didRepack, repackObjectsCfg, err := repackIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldRepackObjects: true,
			repackObjectsCfg: RepackObjectsConfig{
				Strategy: RepackObjectsStrategyIncremental,
			},
		})
		require.NoError(t, err)
		require.True(t, didRepack)
		require.Equal(t, RepackObjectsConfig{
			Strategy: RepackObjectsStrategyIncremental,
		}, repackObjectsCfg)

		requireObjectsState(t, repo, objectsState{
			packfiles: 2,
			hasBitmap: true,
		})
	})

	t.Run("full repack", func(t *testing.T) {
		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		// Create an object and pack it into a packfile.
		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("a"), gittest.WithMessage("a"))
		gittest.Exec(t, cfg, "-C", repoPath, "repack", "-Ad")
		// And a second object that is loose. The full repack should repack both the
		// packfiles and loose objects into a single packfile.
		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("b"), gittest.WithMessage("b"))

		didRepack, repackObjectsCfg, err := repackIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldRepackObjects: true,
			repackObjectsCfg: RepackObjectsConfig{
				Strategy: RepackObjectsStrategyFullWithLooseUnreachable,
			},
		})
		require.NoError(t, err)
		require.True(t, didRepack)
		require.Equal(t, RepackObjectsConfig{
			Strategy: RepackObjectsStrategyFullWithLooseUnreachable,
		}, repackObjectsCfg)

		requireObjectsState(t, repo, objectsState{
			packfiles: 1,
		})
	})

	t.Run("cruft repack with recent unreachable object", func(t *testing.T) {
		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("reachable"), gittest.WithMessage("reachable"))
		gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("unreachable"))

		// The expiry time is before we have written the objects, so they should be packed
		// into a cruft pack.
		expiryTime := time.Now().Add(-1 * time.Hour)

		didRepack, repackObjectsCfg, err := repackIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldRepackObjects: true,
			repackObjectsCfg: RepackObjectsConfig{
				Strategy:          RepackObjectsStrategyFullWithCruft,
				CruftExpireBefore: expiryTime,
			},
		})
		require.NoError(t, err)
		require.True(t, didRepack)
		require.Equal(t, RepackObjectsConfig{
			Strategy:          RepackObjectsStrategyFullWithCruft,
			CruftExpireBefore: expiryTime,
		}, repackObjectsCfg)

		requireObjectsState(t, repo, objectsState{
			packfiles:  2,
			cruftPacks: 1,
		})
	})

	t.Run("cruft repack with expired cruft object", func(t *testing.T) {
		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("reachable"), gittest.WithMessage("reachable"))
		gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("unreachable"))
		gittest.Exec(t, cfg, "-C", repoPath, "repack", "--cruft", "-d")

		// The expiry time is after we have written the cruft pack, so the unreachable
		// object should get pruned.
		expiryTime := time.Now().Add(1 * time.Hour)

		didRepack, repackObjectsCfg, err := repackIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldRepackObjects: true,
			repackObjectsCfg: RepackObjectsConfig{
				Strategy:          RepackObjectsStrategyFullWithCruft,
				CruftExpireBefore: expiryTime,
			},
		})
		require.NoError(t, err)
		require.True(t, didRepack)
		require.Equal(t, RepackObjectsConfig{
			Strategy:          RepackObjectsStrategyFullWithCruft,
			CruftExpireBefore: expiryTime,
		}, repackObjectsCfg)

		requireObjectsState(t, repo, objectsState{
			packfiles:  1,
			cruftPacks: 0,
		})
	})

	t.Run("failed repack returns configuration", func(t *testing.T) {
		repoProto, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})

		gitCmdFactory := errorInjectingCommandFactory{
			CommandFactory: gittest.NewCommandFactory(t, cfg),
			injectedErrors: map[string]error{
				"repack": assert.AnError,
			},
		}

		repo := localrepo.New(config.NewLocator(cfg), gitCmdFactory, nil, repoProto)

		expectedCfg := RepackObjectsConfig{
			Strategy:          RepackObjectsStrategyFullWithCruft,
			CruftExpireBefore: time.Now(),
		}

		didRepack, actualCfg, err := repackIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldRepackObjects: true,
			repackObjectsCfg:    expectedCfg,
		})
		require.Equal(t, fmt.Errorf("repack failed: %w", assert.AnError), err)
		require.False(t, didRepack)
		require.Equal(t, expectedCfg, actualCfg)
	})
}

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

	ctx := testhelper.Context(t)
	cfg := testcfg.Build(t)

	repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
		SkipCreationViaService: true,
	})
	repo := localrepo.NewTestRepo(t, cfg, repoProto)

	// Write an empty commit such that we can create valid refs.
	gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))

	packedRefsPath := filepath.Join(repoPath, "packed-refs")
	looseRefPath := filepath.Join(repoPath, "refs", "heads", "main")

	// We shouldn't pack refs when the strategy says not to.
	didRepack, err := packRefsIfNeeded(ctx, repo, mockOptimizationStrategy{
		shouldRepackReferences: false,
	})
	require.NoError(t, err)
	require.False(t, didRepack)
	// So there should only be the loose reference now.
	require.NoFileExists(t, packedRefsPath)
	require.FileExists(t, looseRefPath)

	// On the other hand, we should repack if the strategy tells us to.
	didRepack, err = packRefsIfNeeded(ctx, repo, mockOptimizationStrategy{
		shouldRepackReferences: true,
	})
	require.NoError(t, err)
	require.True(t, didRepack)
	// There should only be the packed-refs file now.
	require.FileExists(t, packedRefsPath)
	require.NoFileExists(t, looseRefPath)
}

func TestOptimizeRepository(t *testing.T) {
	t.Parallel()
	testhelper.NewFeatureSets(featureflag.GeometricRepacking).Run(t, testOptimizeRepository)
}

func testOptimizeRepository(t *testing.T, ctx context.Context) {
	t.Parallel()

	cfg := testcfg.Build(t)
	txManager := transaction.NewManager(cfg, backchannel.NewRegistry())

	linkRepoToPool := func(t *testing.T, repoPath, poolPath string) {
		t.Helper()
		require.NoError(t, os.WriteFile(
			filepath.Join(repoPath, "objects", "info", "alternates"),
			[]byte(filepath.Join(poolPath, "objects")),
			perm.PrivateFile,
		))
	}

	readPackfiles := func(t *testing.T, repoPath string) []string {
		packPaths, err := filepath.Glob(filepath.Join(repoPath, "objects", "pack", "pack-*.pack"))
		require.NoError(t, err)

		packs := make([]string, 0, len(packPaths))
		for _, packPath := range packPaths {
			packs = append(packs, filepath.Base(packPath))
		}
		return packs
	}

	geometricOrIncrementalMetric := geometricOrIncremental(ctx, "packed_objects_geometric", "packed_objects_incremental")

	type metric struct {
		name, status string
		count        int
	}

	type setupData struct {
		repo                   *localrepo.Repo
		options                []OptimizeRepositoryOption
		expectedErr            error
		expectedMetrics        []metric
		expectedMetricsForPool []metric
	}

	for _, tc := range []struct {
		desc  string
		setup func(t *testing.T, relativePath string) setupData
	}{
		{
			desc: "empty repository does nothing",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository without bitmap repacks objects",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository without commit-graph writes commit-graph",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "-d", "--write-bitmap-index")
				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository with commit-graph without generation data writes commit-graph",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "-d", "--write-bitmap-index")
				gittest.Exec(t, cfg, "-c", "commitGraph.generationVersion=1", "-C", repoPath, "commit-graph", "write", "--split", "--changed-paths")
				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository without multi-pack-index performs incremental repack",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "-d", "-b")
				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository with multiple packfiles packs only for object pool",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})

				// Create two packfiles by creating two objects and then packing
				// twice. Note that the second git-repack(1) is incremental so that
				// we don't remove the first packfile.
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("first"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "--write-bitmap-index")
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("second"), gittest.WithMessage("second"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack")

				gittest.Exec(t, cfg, "-c", "commitGraph.generationVersion=2", "-C", repoPath, "commit-graph", "write", "--split", "--changed-paths")

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: geometricOrIncremental(ctx,
						[]metric{
							{name: "packed_objects_full_with_cruft", status: "success", count: 1},
							{name: "written_bitmap", status: "success", count: 1},
							{name: "written_commit_graph_full", status: "success", count: 1},
							{name: "written_multi_pack_index", status: "success", count: 1},
							{name: "total", status: "success", count: 1},
						},
						[]metric{
							{name: geometricOrIncrementalMetric, status: "success", count: 1},
							{name: "written_bitmap", status: "success", count: 1},
							{name: "written_commit_graph_incremental", status: "success", count: 1},
							{name: "written_multi_pack_index", status: "success", count: 1},
							{name: "total", status: "success", count: 1},
						},
					),
					expectedMetricsForPool: geometricOrIncremental(ctx,
						[]metric{
							{name: "packed_objects_full_with_unreachable", status: "success", count: 1},
							{name: "written_bitmap", status: "success", count: 1},
							{name: "written_commit_graph_incremental", status: "success", count: 1},
							{name: "written_multi_pack_index", status: "success", count: 1},
							{name: "total", status: "success", count: 1},
						},
						[]metric{
							{name: "packed_objects_full_with_loose_unreachable", status: "success", count: 1},
							{name: "written_bitmap", status: "success", count: 1},
							{name: "written_commit_graph_incremental", status: "success", count: 1},
							{name: "written_multi_pack_index", status: "success", count: 1},
							{name: "total", status: "success", count: 1},
						},
					),
				}
			},
		},
		{
			desc: "well-packed repository does not optimize",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "-d", "--write-bitmap-index")
				gittest.Exec(t, cfg, "-c", "commitGraph.generationVersion=2", "-C", repoPath, "commit-graph", "write", "--split", "--changed-paths")
				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_commit_graph_incremental", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "well-packed repository with multi-pack-index does not optimize",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "-d", "--write-bitmap-index", "--write-midx")
				gittest.Exec(t, cfg, "-c", "commitGraph.generationVersion=2", "-C", repoPath, "commit-graph", "write", "--split", "--changed-paths")
				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "recent loose objects don't get pruned",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "-d", "--write-bitmap-index")
				gittest.Exec(t, cfg, "-c", "commitGraph.generationVersion=2", "-C", repoPath, "commit-graph", "write", "--split", "--changed-paths")

				// The repack won't repack the following objects because they're
				// broken, and thus we'll retry to prune them afterwards.
				require.NoError(t, os.MkdirAll(filepath.Join(repoPath, "objects", "17"), perm.SharedDir))

				// We set the object's mtime to be almost two weeks ago. Given that
				// our timeout is at exactly two weeks this shouldn't caused them to
				// get pruned.
				almostTwoWeeksAgo := time.Now().Add(stats.StaleObjectsGracePeriod).Add(time.Minute)

				for i := 0; i < looseObjectLimit+1; i++ {
					blobPath := filepath.Join(repoPath, "objects", "17", fmt.Sprintf("%d", i))
					require.NoError(t, os.WriteFile(blobPath, nil, perm.SharedFile))
					require.NoError(t, os.Chtimes(blobPath, almostTwoWeeksAgo, almostTwoWeeksAgo))
				}

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_commit_graph_incremental", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "old loose objects get pruned",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "-d", "--write-bitmap-index")
				gittest.Exec(t, cfg, "-c", "commitGraph.generationVersion=2", "-C", repoPath, "commit-graph", "write", "--split", "--changed-paths")

				// The repack won't repack the following objects because they're
				// broken, and thus we'll retry to prune them afterwards.
				require.NoError(t, os.MkdirAll(filepath.Join(repoPath, "objects", "17"), perm.SharedDir))

				moreThanTwoWeeksAgo := time.Now().Add(stats.StaleObjectsGracePeriod).Add(-time.Minute)

				for i := 0; i < looseObjectLimit+1; i++ {
					blobPath := filepath.Join(repoPath, "objects", "17", fmt.Sprintf("%d", i))
					require.NoError(t, os.WriteFile(blobPath, nil, perm.SharedFile))
					require.NoError(t, os.Chtimes(blobPath, moreThanTwoWeeksAgo, moreThanTwoWeeksAgo))
				}

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "pruned_objects", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
					// Object pools never prune objects.
					expectedMetricsForPool: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_commit_graph_incremental", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "loose refs get packed",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})

				for i := 0; i < 16; i++ {
					gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch(fmt.Sprintf("branch-%d", i)))
				}

				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-A", "--write-bitmap-index")
				gittest.Exec(t, cfg, "-c", "commitGraph.generationVersion=2", "-C", repoPath, "commit-graph", "write", "--split", "--changed-paths")

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "success", count: 1},
						{name: "packed_refs", status: "success", count: 1},
						{name: "written_commit_graph_incremental", status: "success", count: 1},
						{name: "written_bitmap", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository connected to empty object pool",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("some-branch"))

				_, poolPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           gittest.NewObjectPoolName(t),
				})

				linkRepoToPool(t, repoPath, poolPath)

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: "packed_objects_incremental", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository with all objects deduplicated via pool",
			setup: func(t *testing.T, relativePath string) setupData {
				_, poolPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           gittest.NewObjectPoolName(t),
				})
				commitID := gittest.WriteCommit(t, cfg, poolPath)

				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				linkRepoToPool(t, repoPath, poolPath)
				gittest.WriteRef(t, cfg, repoPath, "refs/heads/some-branch", commitID)

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository with some deduplicated objects",
			setup: func(t *testing.T, relativePath string) setupData {
				_, poolPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           gittest.NewObjectPoolName(t),
				})
				commitID := gittest.WriteCommit(t, cfg, poolPath)

				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				linkRepoToPool(t, repoPath, poolPath)
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(commitID), gittest.WithBranch("some-branch"))

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: "packed_objects_incremental", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository with some deduplicated objects and eager strategy",
			setup: func(t *testing.T, relativePath string) setupData {
				_, poolPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           gittest.NewObjectPoolName(t),
				})
				commitID := gittest.WriteCommit(t, cfg, poolPath)

				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				linkRepoToPool(t, repoPath, poolPath)
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(commitID), gittest.WithBranch("some-branch"))

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					options: []OptimizeRepositoryOption{
						WithOptimizationStrategyConstructor(func(repoInfo stats.RepositoryInfo) OptimizationStrategy {
							return NewEagerOptimizationStrategy(repoInfo)
						}),
					},
					expectedMetrics: []metric{
						{name: "packed_refs", status: "success", count: 1},
						{name: "pruned_objects", status: "success", count: 1},
						{name: "packed_objects_full_with_cruft", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
					expectedMetricsForPool: []metric{
						{name: "packed_refs", status: "success", count: 1},
						{name: "packed_objects_full_with_unreachable", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "repository with same packfile in pool",
			setup: func(t *testing.T, relativePath string) setupData {
				_, poolPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           gittest.NewObjectPoolName(t),
				})
				gittest.WriteCommit(t, cfg, poolPath, gittest.WithBranch("some-branch"))
				gittest.Exec(t, cfg, "-C", poolPath, "repack", "-Ad")

				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("some-branch"))
				gittest.Exec(t, cfg, "-C", repoPath, "repack", "-Ad")

				repoPackfiles := readPackfiles(t, repoPath)
				require.Len(t, repoPackfiles, 1)

				// Assert that the packfiles in both the repository and the object
				// pool are actually the same. This is likely to happen e.g. when
				// the object pool has just been created and the repository was
				// linked to it and has caused bugs with geometric repacking in the
				// past.
				require.Equal(t, repoPackfiles, readPackfiles(t, poolPath))

				linkRepoToPool(t, repoPath, poolPath)

				return setupData{
					repo: localrepo.NewTestRepo(t, cfg, repo),
					expectedMetrics: []metric{
						{name: "packed_objects_incremental", status: "success", count: 1},
						{name: "written_commit_graph_full", status: "success", count: 1},
						{name: "written_multi_pack_index", status: "success", count: 1},
						{name: "total", status: "success", count: 1},
					},
				}
			},
		},
		{
			desc: "failing repack",
			setup: func(t *testing.T, relativePath string) setupData {
				repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
					SkipCreationViaService: true,
					RelativePath:           relativePath,
				})
				gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("branch"))

				gitCmdFactory := errorInjectingCommandFactory{
					CommandFactory: gittest.NewCommandFactory(t, cfg),
					injectedErrors: map[string]error{
						"repack": assert.AnError,
					},
				}

				return setupData{
					repo: localrepo.New(config.NewLocator(cfg), gitCmdFactory, nil, repo),
					expectedMetrics: []metric{
						{name: geometricOrIncrementalMetric, status: "failure", count: 1},
						{name: "written_bitmap", status: "failure", count: 1},
						{name: "written_multi_pack_index", status: "failure", count: 1},
						{name: "total", status: "failure", count: 1},
					},
					expectedErr: fmt.Errorf("could not repack: %w", fmt.Errorf("repack failed: %w", assert.AnError)),
				}
			},
		},
	} {
		tc := tc

		testRepoAndPool(t, tc.desc, func(t *testing.T, relativePath string) {
			t.Parallel()

			setup := tc.setup(t, relativePath)

			manager := NewManager(cfg.Prometheus, txManager)

			err := manager.OptimizeRepository(ctx, setup.repo, setup.options...)
			require.Equal(t, setup.expectedErr, err)

			expectedMetrics := setup.expectedMetrics
			if stats.IsPoolRepository(setup.repo) && setup.expectedMetricsForPool != nil {
				expectedMetrics = setup.expectedMetricsForPool
			}

			var buf bytes.Buffer
			_, err = buf.WriteString("# HELP gitaly_housekeeping_tasks_total Total number of housekeeping tasks performed in the repository\n")
			require.NoError(t, err)
			_, err = buf.WriteString("# TYPE gitaly_housekeeping_tasks_total counter\n")
			require.NoError(t, err)

			for _, metric := range expectedMetrics {
				_, err := buf.WriteString(fmt.Sprintf(
					"gitaly_housekeeping_tasks_total{housekeeping_task=%q, status=%q} %d\n",
					metric.name, metric.status, metric.count,
				))
				require.NoError(t, err)
			}

			require.NoError(t, testutil.CollectAndCompare(
				manager.tasksTotal, &buf, "gitaly_housekeeping_tasks_total",
			))
		})
	}
}

func TestOptimizeRepository_ConcurrencyLimit(t *testing.T) {
	t.Parallel()
	ctx := testhelper.Context(t)
	cfg := testcfg.Build(t)

	t.Run("subsequent calls get skipped", func(t *testing.T) {
		reqReceivedCh, ch := make(chan struct{}), make(chan struct{})

		repoProto, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		manager := NewManager(gitalycfgprom.Config{}, nil)
		manager.optimizeFunc = func(context.Context, *RepositoryManager, *localrepo.Repo, OptimizationStrategy) error {
			reqReceivedCh <- struct{}{}
			ch <- struct{}{}

			return nil
		}

		go func() {
			require.NoError(t, manager.OptimizeRepository(ctx, repo))
		}()

		<-reqReceivedCh
		// When repository optimizations are performed for a specific repository already,
		// then any subsequent calls to the same repository should just return immediately
		// without doing any optimizations at all.
		require.NoError(t, manager.OptimizeRepository(ctx, repo))

		<-ch
	})

	t.Run("multiple repositories concurrently", func(t *testing.T) {
		reqReceivedCh, ch := make(chan struct{}), make(chan struct{})

		repoProtoFirst, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repoFirst := localrepo.NewTestRepo(t, cfg, repoProtoFirst)
		repoProtoSecond, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repoSecond := localrepo.NewTestRepo(t, cfg, repoProtoSecond)

		reposOptimized := make(map[string]struct{})

		manager := NewManager(gitalycfgprom.Config{}, nil)
		manager.optimizeFunc = func(_ context.Context, _ *RepositoryManager, repo *localrepo.Repo, _ OptimizationStrategy) error {
			reposOptimized[repo.GetRelativePath()] = struct{}{}

			if repo.GitRepo.GetRelativePath() == repoFirst.GetRelativePath() {
				reqReceivedCh <- struct{}{}
				ch <- struct{}{}
			}

			return nil
		}

		// We block in the first call so that we can assert that a second call
		// to a different repository performs the optimization regardless without blocking.
		go func() {
			require.NoError(t, manager.OptimizeRepository(ctx, repoFirst))
		}()

		<-reqReceivedCh

		// Because this optimizes a different repository this call shouldn't block.
		require.NoError(t, manager.OptimizeRepository(ctx, repoSecond))

		<-ch

		assert.Contains(t, reposOptimized, repoFirst.GetRelativePath())
		assert.Contains(t, reposOptimized, repoSecond.GetRelativePath())
	})

	t.Run("serialized optimizations", func(t *testing.T) {
		reqReceivedCh, ch := make(chan struct{}), make(chan struct{})
		repoProto, _ := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)
		var optimizations int

		manager := NewManager(gitalycfgprom.Config{}, nil)
		manager.optimizeFunc = func(context.Context, *RepositoryManager, *localrepo.Repo, OptimizationStrategy) error {
			optimizations++

			if optimizations == 1 {
				reqReceivedCh <- struct{}{}
				ch <- struct{}{}
			}

			return nil
		}

		var wg sync.WaitGroup
		wg.Add(1)
		go func() {
			defer wg.Done()
			require.NoError(t, manager.OptimizeRepository(ctx, repo))
		}()

		<-reqReceivedCh

		// Because we already have a concurrent call which optimizes the repository we expect
		// that all subsequent calls which try to optimize the same repository return immediately.
		// Furthermore, we expect to see only a single call to the optimizing function because we
		// don't want to optimize the same repository concurrently.
		require.NoError(t, manager.OptimizeRepository(ctx, repo))
		require.NoError(t, manager.OptimizeRepository(ctx, repo))
		require.NoError(t, manager.OptimizeRepository(ctx, repo))
		assert.Equal(t, 1, optimizations)

		<-ch
		wg.Wait()

		// When performing optimizations sequentially though the repository
		// should be unlocked after every call, and consequentially we should
		// also see multiple calls to the optimizing function.
		require.NoError(t, manager.OptimizeRepository(ctx, repo))
		require.NoError(t, manager.OptimizeRepository(ctx, repo))
		require.NoError(t, manager.OptimizeRepository(ctx, repo))
		assert.Equal(t, 4, optimizations)
	})
}

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

	ctx := testhelper.Context(t)
	cfg := testcfg.Build(t)

	repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
		SkipCreationViaService: true,
	})
	repo := localrepo.NewTestRepo(t, cfg, repoProto)

	objectPath := func(oid git.ObjectID) string {
		return filepath.Join(repoPath, "objects", oid.String()[0:2], oid.String()[2:])
	}

	// Write two blobs, one recent blob and one blob that is older than two weeks and that would
	// thus get pruned.
	recentBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("recent"))
	staleBlobID := gittest.WriteBlob(t, cfg, repoPath, []byte("stale"))
	twoWeeksAgo := time.Now().Add(-1 * 2 * 7 * 24 * time.Hour)
	require.NoError(t, os.Chtimes(objectPath(staleBlobID), twoWeeksAgo, twoWeeksAgo))

	// We shouldn't prune when the strategy determines there aren't enough old objects.
	didPrune, err := pruneIfNeeded(ctx, repo, mockOptimizationStrategy{
		shouldPruneObjects: false,
		pruneObjectsCfg: PruneObjectsConfig{
			ExpireBefore: twoWeeksAgo,
		},
	})
	require.NoError(t, err)
	require.False(t, didPrune)

	// Consequentially, the objects shouldn't have been pruned.
	require.FileExists(t, objectPath(recentBlobID))
	require.FileExists(t, objectPath(staleBlobID))

	// But we naturally should prune if told so.
	didPrune, err = pruneIfNeeded(ctx, repo, mockOptimizationStrategy{
		shouldPruneObjects: true,
		pruneObjectsCfg: PruneObjectsConfig{
			ExpireBefore: twoWeeksAgo,
		},
	})
	require.NoError(t, err)
	require.True(t, didPrune)

	// But we should only prune the stale blob, never the recent one.
	require.FileExists(t, objectPath(recentBlobID))
	require.NoFileExists(t, objectPath(staleBlobID))
}

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

	ctx := testhelper.Context(t)
	cfg := testcfg.Build(t)

	t.Run("strategy does not update commit-graph", func(t *testing.T) {
		t.Parallel()

		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))

		written, cfg, err := writeCommitGraphIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldWriteCommitGraph: false,
		})
		require.NoError(t, err)
		require.False(t, written)
		require.Equal(t, WriteCommitGraphConfig{}, cfg)

		require.NoFileExists(t, filepath.Join(repoPath, "objects", "info", "commit-graph"))
		require.NoDirExists(t, filepath.Join(repoPath, "objects", "info", "commit-graphs"))
	})

	t.Run("strategy does update commit-graph", func(t *testing.T) {
		t.Parallel()

		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))

		written, cfg, err := writeCommitGraphIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldWriteCommitGraph: true,
		})
		require.NoError(t, err)
		require.True(t, written)
		require.Equal(t, WriteCommitGraphConfig{}, cfg)

		require.NoFileExists(t, filepath.Join(repoPath, "objects", "info", "commit-graph"))
		require.DirExists(t, filepath.Join(repoPath, "objects", "info", "commit-graphs"))
	})

	t.Run("commit-graph with pruned objects", func(t *testing.T) {
		t.Parallel()

		repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
			SkipCreationViaService: true,
		})
		repo := localrepo.NewTestRepo(t, cfg, repoProto)

		// Write a first commit-graph that contains the root commit, only.
		rootCommitID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithBranch("main"))
		gittest.Exec(t, cfg, "-C", repoPath, "commit-graph", "write", "--reachable", "--split", "--changed-paths")

		// Write a second, incremental commit-graph that contains a commit we're about to
		// make unreachable and then prune.
		unreachableCommitID := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(rootCommitID), gittest.WithBranch("main"))
		gittest.Exec(t, cfg, "-C", repoPath, "commit-graph", "write", "--reachable", "--split=no-merge", "--changed-paths")

		// Reset the "main" branch back to the initial root commit ID and prune the now
		// unreachable second commit.
		gittest.Exec(t, cfg, "-C", repoPath, "update-ref", "refs/heads/main", rootCommitID.String())
		gittest.Exec(t, cfg, "-C", repoPath, "prune", "--expire", "now")

		// The commit-graph chain now refers to the pruned commit, and git-commit-graph(1)
		// should complain about that.
		var stderr bytes.Buffer
		verifyCmd := gittest.NewCommand(t, cfg, "-C", repoPath, "commit-graph", "verify")
		verifyCmd.Stderr = &stderr
		require.EqualError(t, verifyCmd.Run(), "exit status 1")
		require.Equal(t, stderr.String(), fmt.Sprintf("error: Could not read %[1]s\nfailed to parse commit %[1]s from object database for commit-graph\n", unreachableCommitID))

		// Write the commit-graph incrementally.
		didWrite, writeCommitGraphCfg, err := writeCommitGraphIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldWriteCommitGraph: true,
		})
		require.NoError(t, err)
		require.True(t, didWrite)
		require.Equal(t, WriteCommitGraphConfig{}, writeCommitGraphCfg)

		// We should still observe the failure failure.
		stderr.Reset()
		verifyCmd = gittest.NewCommand(t, cfg, "-C", repoPath, "commit-graph", "verify")
		verifyCmd.Stderr = &stderr
		require.EqualError(t, verifyCmd.Run(), "exit status 1")
		require.Equal(t, stderr.String(), fmt.Sprintf("error: Could not read %[1]s\nfailed to parse commit %[1]s from object database for commit-graph\n", unreachableCommitID))

		// Write the commit-graph a second time, but this time we ask to rewrite the
		// commit-graph completely.
		didWrite, writeCommitGraphCfg, err = writeCommitGraphIfNeeded(ctx, repo, mockOptimizationStrategy{
			shouldWriteCommitGraph: true,
			writeCommitGraphCfg: WriteCommitGraphConfig{
				ReplaceChain: true,
			},
		})
		require.NoError(t, err)
		require.True(t, didWrite)
		require.Equal(t, WriteCommitGraphConfig{
			ReplaceChain: true,
		}, writeCommitGraphCfg)

		// The commit-graph should now have been fixed.
		gittest.Exec(t, cfg, "-C", repoPath, "commit-graph", "verify")
	})
}
