package git_test

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"syscall"
	"testing"

	"github.com/sirupsen/logrus"
	"github.com/stretchr/testify/require"
	"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/trace2"
	"gitlab.com/gitlab-org/gitaly/v15/internal/git/trace2hooks"
	"gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config"
	"gitlab.com/gitlab-org/gitaly/v15/internal/helper/text"
	"gitlab.com/gitlab-org/gitaly/v15/internal/testhelper"
	"gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg"
	"gitlab.com/gitlab-org/gitaly/v15/internal/tracing"
)

func TestGitCommandProxy(t *testing.T) {
	cfg := testcfg.Build(t)

	requestReceived := false

	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestReceived = true
	}))
	defer ts.Close()

	t.Setenv("http_proxy", ts.URL)

	ctx := testhelper.Context(t)

	dir := testhelper.TempDir(t)

	gitCmdFactory, cleanup, err := git.NewExecCommandFactory(cfg)
	require.NoError(t, err)
	defer cleanup()

	cmd, err := gitCmdFactory.NewWithoutRepo(ctx, git.Command{
		Name: "clone",
		Args: []string{"http://gitlab.com/bogus-repo", dir},
	}, git.WithDisabledHooks())
	require.NoError(t, err)

	err = cmd.Wait()
	require.NoError(t, err)
	require.True(t, requestReceived)
}

// Global git configuration is only disabled in tests for now. Gitaly should stop using the global
// git configuration in 15.0. See https://gitlab.com/gitlab-org/gitaly/-/issues/3617.
func TestExecCommandFactory_globalGitConfigIgnored(t *testing.T) {
	cfg := testcfg.Build(t)

	gitCmdFactory, cleanup, err := git.NewExecCommandFactory(cfg)
	require.NoError(t, err)
	defer cleanup()

	tmpHome := testhelper.TempDir(t)
	require.NoError(t, os.WriteFile(filepath.Join(tmpHome, ".gitconfig"), []byte(`[ignored]
	value = true
`,
	), os.ModePerm))
	ctx := testhelper.Context(t)

	for _, tc := range []struct {
		desc   string
		filter string
	}{
		{desc: "global", filter: "--global"},
		// The test doesn't override the system config as that would be a global change or would
		// require chrooting. The assertion won't catch problems on systems that do not have system
		// level configuration set.
		{desc: "system", filter: "--system"},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			cmd, err := gitCmdFactory.NewWithoutRepo(ctx, git.Command{
				Name:  "config",
				Flags: []git.Option{git.Flag{Name: "--list"}, git.Flag{Name: tc.filter}},
			}, git.WithEnv("HOME="+tmpHome))
			require.NoError(t, err)

			configContents, err := io.ReadAll(cmd)
			require.NoError(t, err)
			require.NoError(t, cmd.Wait())
			require.Empty(t, string(configContents))
		})
	}
}

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

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

	repo, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
		SkipCreationViaService: true,
	})
	require.NoError(t, os.Remove(filepath.Join(repoPath, "config")))

	defaultConfig := func() []string {
		commandFactory, cleanup, err := git.NewExecCommandFactory(cfg)
		require.NoError(t, err)
		defer cleanup()

		globalConfig, err := commandFactory.GlobalConfiguration(ctx)
		require.NoError(t, err)

		var configEntries []string
		for _, config := range globalConfig {
			configEntries = append(configEntries, fmt.Sprintf(
				"%s=%s", strings.ToLower(config.Key), config.Value,
			))
		}
		return configEntries
	}

	for _, tc := range []struct {
		desc           string
		config         []config.GitConfig
		options        []git.CmdOpt
		expectedConfig []string
	}{
		{
			desc:           "without config",
			expectedConfig: defaultConfig(),
		},
		{
			desc: "config with simple entry",
			config: []config.GitConfig{
				{Key: "core.foo", Value: "bar"},
			},
			expectedConfig: append(defaultConfig(), "core.foo=bar"),
		},
		{
			desc: "config with empty value",
			config: []config.GitConfig{
				{Key: "core.empty", Value: ""},
			},
			expectedConfig: append(defaultConfig(), "core.empty="),
		},
		{
			desc: "config with subsection",
			config: []config.GitConfig{
				{Key: "http.http://example.com.proxy", Value: "http://proxy.example.com"},
			},
			expectedConfig: append(defaultConfig(), "http.http://example.com.proxy=http://proxy.example.com"),
		},
		{
			desc: "config with multiple keys",
			config: []config.GitConfig{
				{Key: "core.foo", Value: "initial"},
				{Key: "core.foo", Value: "second"},
			},
			expectedConfig: append(defaultConfig(), "core.foo=initial", "core.foo=second"),
		},
		{
			desc: "option",
			options: []git.CmdOpt{
				git.WithConfig(git.ConfigPair{Key: "core.foo", Value: "bar"}),
			},
			expectedConfig: append(defaultConfig(), "core.foo=bar"),
		},
		{
			desc: "multiple options",
			options: []git.CmdOpt{
				git.WithConfig(
					git.ConfigPair{Key: "core.foo", Value: "initial"},
					git.ConfigPair{Key: "core.foo", Value: "second"},
				),
			},
			expectedConfig: append(defaultConfig(), "core.foo=initial", "core.foo=second"),
		},
		{
			desc: "config comes after options",
			config: []config.GitConfig{
				{Key: "from.config", Value: "value"},
			},
			options: []git.CmdOpt{
				git.WithConfig(
					git.ConfigPair{Key: "from.option", Value: "value"},
				),
			},
			expectedConfig: append(defaultConfig(), "from.option=value", "from.config=value"),
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			cfg.Git.Config = tc.config

			commandFactory, cleanup, err := git.NewExecCommandFactory(cfg)
			require.NoError(t, err)
			defer cleanup()

			var stdout bytes.Buffer
			cmd, err := commandFactory.New(ctx, repo, git.Command{
				Name: "config",
				Flags: []git.Option{
					git.Flag{Name: "--list"},
				},
			}, append(tc.options, git.WithStdout(&stdout))...)
			require.NoError(t, err)
			require.NoError(t, cmd.Wait())
			require.Equal(t, tc.expectedConfig, strings.Split(text.ChompBytes(stdout.Bytes()), "\n"))
		})
	}
}

func TestCommandFactory_ExecutionEnvironment(t *testing.T) {
	testhelper.Unsetenv(t, "GITALY_TESTING_GIT_BINARY")
	testhelper.Unsetenv(t, "GITALY_TESTING_BUNDLED_GIT_PATH")

	ctx := testhelper.Context(t)

	assertExecEnv := func(t *testing.T, cfg config.Cfg, expectedExecEnv git.ExecutionEnvironment) {
		t.Helper()
		gitCmdFactory, cleanup, err := git.NewExecCommandFactory(cfg, git.WithSkipHooks())
		require.NoError(t, err)
		defer cleanup()

		// We need to compare the execution environments manually because they also have
		// some private variables which we cannot easily check here.
		actualExecEnv := gitCmdFactory.GetExecutionEnvironment(ctx)
		require.Equal(t, expectedExecEnv.BinaryPath, actualExecEnv.BinaryPath)
		require.Equal(t, expectedExecEnv.EnvironmentVariables, actualExecEnv.EnvironmentVariables)
	}

	t.Run("set in config without ignored gitconfig", func(t *testing.T) {
		assertExecEnv(t, config.Cfg{
			Git: config.Git{
				BinPath:         "/path/to/myGit",
				IgnoreGitconfig: false,
			},
		}, git.ExecutionEnvironment{
			BinaryPath: "/path/to/myGit",
			EnvironmentVariables: []string{
				"LANG=en_US.UTF-8",
				"GIT_TERMINAL_PROMPT=0",
			},
		})
	})

	t.Run("set in config", func(t *testing.T) {
		assertExecEnv(t, config.Cfg{
			Git: config.Git{
				BinPath:         "/path/to/myGit",
				IgnoreGitconfig: true,
			},
		}, git.ExecutionEnvironment{
			BinaryPath: "/path/to/myGit",
			EnvironmentVariables: []string{
				"LANG=en_US.UTF-8",
				"GIT_TERMINAL_PROMPT=0",
				"GIT_CONFIG_GLOBAL=/dev/null",
				"GIT_CONFIG_SYSTEM=/dev/null",
				"XDG_CONFIG_HOME=/dev/null",
			},
		})
	})

	t.Run("set using GITALY_TESTING_GIT_BINARY", func(t *testing.T) {
		t.Setenv("GITALY_TESTING_GIT_BINARY", "/path/to/env_git")

		assertExecEnv(t, config.Cfg{
			Git: config.Git{
				IgnoreGitconfig: true,
			},
		}, git.ExecutionEnvironment{
			BinaryPath: "/path/to/env_git",
			EnvironmentVariables: []string{
				"NO_SET_GIT_TEMPLATE_DIR=YesPlease",
				"LANG=en_US.UTF-8",
				"GIT_TERMINAL_PROMPT=0",
				"GIT_CONFIG_GLOBAL=/dev/null",
				"GIT_CONFIG_SYSTEM=/dev/null",
				"XDG_CONFIG_HOME=/dev/null",
			},
		})
	})

	t.Run("set using GITALY_TESTING_BUNDLED_GIT_PATH", func(t *testing.T) {
		writeExecutables := func(t *testing.T, dir string, executables ...string) {
			for _, executable := range executables {
				testhelper.WriteExecutable(t, filepath.Join(dir, executable), nil)
			}
		}

		missingBinaryError := func(dir, binary string) error {
			return fmt.Errorf("setting up Git execution environment: %w",
				fmt.Errorf("constructing Git environment: %w",
					fmt.Errorf("statting %q: %w", binary,
						&fs.PathError{
							Op:   "stat",
							Path: filepath.Join(dir, binary),
							Err:  syscall.ENOENT,
						},
					),
				),
			)
		}

		type setupData struct {
			binaryDir     string
			bundledGitDir string
			expectedErr   error
		}

		for _, tc := range []struct {
			desc  string
			setup func(t *testing.T) setupData
		}{
			{
				desc: "unset binary directory",
				setup: func(t *testing.T) setupData {
					return setupData{
						binaryDir:     "",
						bundledGitDir: "/does/not/exist",
						expectedErr: fmt.Errorf("setting up Git execution environment: %w",
							fmt.Errorf("constructing Git environment: %w",
								errors.New("cannot use bundled binaries without bin path being set"),
							),
						),
					}
				},
			},
			{
				desc: "missing directory",
				setup: func(t *testing.T) setupData {
					return setupData{
						binaryDir:     testhelper.TempDir(t),
						bundledGitDir: "/does/not/exist",
						expectedErr:   missingBinaryError("/does/not/exist", "gitaly-git-v2.38"),
					}
				},
			},
			{
				desc: "missing gitaly-git",
				setup: func(t *testing.T) setupData {
					bundledGitDir := testhelper.TempDir(t)

					return setupData{
						binaryDir:     testhelper.TempDir(t),
						bundledGitDir: bundledGitDir,
						expectedErr:   missingBinaryError(bundledGitDir, "gitaly-git-v2.38"),
					}
				},
			},
			{
				desc: "missing gitaly-git-remote-http",
				setup: func(t *testing.T) setupData {
					bundledGitDir := testhelper.TempDir(t)

					writeExecutables(t, bundledGitDir, "gitaly-git-v2.38")

					return setupData{
						binaryDir:     testhelper.TempDir(t),
						bundledGitDir: bundledGitDir,
						expectedErr:   missingBinaryError(bundledGitDir, "gitaly-git-remote-http-v2.38"),
					}
				},
			},
			{
				desc: "missing gitaly-git-http-backend",
				setup: func(t *testing.T) setupData {
					bundledGitDir := testhelper.TempDir(t)

					writeExecutables(t, bundledGitDir,
						"gitaly-git-v2.38",
						"gitaly-git-remote-http-v2.38",
					)

					return setupData{
						binaryDir:     testhelper.TempDir(t),
						bundledGitDir: bundledGitDir,
						expectedErr:   missingBinaryError(bundledGitDir, "gitaly-git-http-backend-v2.38"),
					}
				},
			},
			{
				desc: "successful",
				setup: func(t *testing.T) setupData {
					bundledGitDir := testhelper.TempDir(t)
					writeExecutables(t, bundledGitDir, "gitaly-git-v2.38", "gitaly-git-remote-http-v2.38", "gitaly-git-http-backend-v2.38")

					return setupData{
						binaryDir:     testhelper.TempDir(t),
						bundledGitDir: bundledGitDir,
					}
				},
			},
		} {
			t.Run(tc.desc, func(t *testing.T) {
				setupData := tc.setup(t)
				t.Setenv("GITALY_TESTING_BUNDLED_GIT_PATH", setupData.bundledGitDir)

				gitCmdFactory, cleanup, err := git.NewExecCommandFactory(
					config.Cfg{
						BinDir: setupData.binaryDir,
					},
					git.WithSkipHooks(),
					// Override the constructors so that we don't have to change
					// tests every time we're upgrading our bundled Git version.
					git.WithExecutionEnvironmentConstructors(
						git.BundledGitEnvironmentConstructor{
							Suffix: "-v2.38",
						},
					),
				)
				require.Equal(t, setupData.expectedErr, err)
				if err != nil {
					return
				}
				defer cleanup()

				// When the command factory has been successfully created then we
				// verify that the symlink that it has created match what we expect.
				gitBinPath := gitCmdFactory.GetExecutionEnvironment(ctx).BinaryPath
				for _, executable := range []string{"git", "git-remote-http", "git-http-backend"} {
					symlinkPath := filepath.Join(filepath.Dir(gitBinPath), executable)

					// The symlink in Git's temporary BinPath points to the Git
					// executable in Gitaly's BinDir.
					target, err := os.Readlink(symlinkPath)
					require.NoError(t, err)
					require.Equal(t, filepath.Join(setupData.binaryDir, "gitaly-"+executable+"-v2.38"), target)

					// And in a test setup, the symlink in Gitaly's BinDir must point to
					// the Git binary pointed to by the GITALY_TESTING_BUNDLED_GIT_PATH
					// environment variable.
					target, err = os.Readlink(target)
					require.NoError(t, err)
					require.Equal(t, filepath.Join(setupData.bundledGitDir, "gitaly-"+executable+"-v2.38"), target)
				}
			})
		}
	})

	t.Run("not set, get from system", func(t *testing.T) {
		resolvedPath, err := exec.LookPath("git")
		require.NoError(t, err)

		assertExecEnv(t, config.Cfg{
			Git: config.Git{
				IgnoreGitconfig: true,
			},
		}, git.ExecutionEnvironment{
			BinaryPath: resolvedPath,
			EnvironmentVariables: []string{
				"LANG=en_US.UTF-8",
				"GIT_TERMINAL_PROMPT=0",
				"GIT_CONFIG_GLOBAL=/dev/null",
				"GIT_CONFIG_SYSTEM=/dev/null",
				"XDG_CONFIG_HOME=/dev/null",
			},
		})
	})

	t.Run("doesn't exist in the system", func(t *testing.T) {
		testhelper.Unsetenv(t, "PATH")

		_, _, err := git.NewExecCommandFactory(config.Cfg{}, git.WithSkipHooks())
		require.EqualError(t, err, "setting up Git execution environment: could not set up any Git execution environments")
	})
}

func TestExecCommandFactoryHooksPath(t *testing.T) {
	ctx := testhelper.Context(t)

	t.Run("temporary hooks", func(t *testing.T) {
		cfg := config.Cfg{
			BinDir: testhelper.TempDir(t),
		}

		t.Run("no overrides", func(t *testing.T) {
			gitCmdFactory := gittest.NewCommandFactory(t, cfg)

			hooksPath := gitCmdFactory.HooksPath(ctx)

			// We cannot assert that the hooks path is equal to any specific
			// string, but instead we can assert that it exists and contains the
			// symlinks we expect.
			for _, hook := range []string{"update", "pre-receive", "post-receive", "reference-transaction"} {
				target, err := os.Readlink(filepath.Join(hooksPath, hook))
				require.NoError(t, err)
				require.Equal(t, cfg.BinaryPath("gitaly-hooks"), target)
			}
		})

		t.Run("with skip", func(t *testing.T) {
			gitCmdFactory := gittest.NewCommandFactory(t, cfg, git.WithSkipHooks())
			require.Equal(t, "/var/empty", gitCmdFactory.HooksPath(ctx))
		})
	})

	t.Run("hooks path", func(t *testing.T) {
		gitCmdFactory := gittest.NewCommandFactory(t, config.Cfg{
			BinDir: testhelper.TempDir(t),
		}, git.WithHooksPath("/hooks/path"))

		// The environment variable shouldn't override an explicitly set hooks path.
		require.Equal(t, "/hooks/path", gitCmdFactory.HooksPath(ctx))
	})
}

func TestExecCommandFactory_GitVersion(t *testing.T) {
	ctx := testhelper.Context(t)

	generateVersionScript := func(version string) func(git.ExecutionEnvironment) string {
		return func(git.ExecutionEnvironment) string {
			//nolint:gitaly-linters
			return fmt.Sprintf(
				`#!/usr/bin/env bash
				echo '%s'
			`, version)
		}
	}

	for _, tc := range []struct {
		desc            string
		versionString   string
		expectedErr     string
		expectedVersion string
	}{
		{
			desc:            "valid version",
			versionString:   "git version 2.33.1.gl1",
			expectedVersion: "2.33.1.gl1",
		},
		{
			desc:            "valid version with trailing newline",
			versionString:   "git version 2.33.1.gl1\n",
			expectedVersion: "2.33.1.gl1",
		},
		{
			desc:          "multi-line version",
			versionString: "git version 2.33.1.gl1\nfoobar\n",
			expectedErr:   "cannot parse git version: strconv.ParseUint: parsing \"1\\nfoobar\": invalid syntax",
		},
		{
			desc:          "unexpected format",
			versionString: "2.33.1\n",
			expectedErr:   "invalid version format: \"2.33.1\\n\\n\"",
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			gitCmdFactory := gittest.NewInterceptingCommandFactory(
				t, ctx, testcfg.Build(t), generateVersionScript(tc.versionString),
				gittest.WithRealCommandFactoryOptions(git.WithSkipHooks()),
				gittest.WithInterceptedVersion(),
			)

			actualVersion, err := gitCmdFactory.GitVersion(ctx)
			if tc.expectedErr == "" {
				require.NoError(t, err)
			} else {
				require.EqualError(t, err, tc.expectedErr)
			}
			require.Equal(t, tc.expectedVersion, actualVersion.String())
		})
	}

	t.Run("caching", func(t *testing.T) {
		gitCmdFactory := gittest.NewInterceptingCommandFactory(
			t, ctx, testcfg.Build(t), generateVersionScript("git version 1.2.3"),
			gittest.WithRealCommandFactoryOptions(git.WithSkipHooks()),
			gittest.WithInterceptedVersion(),
		)

		gitPath := gitCmdFactory.GetExecutionEnvironment(ctx).BinaryPath
		stat, err := os.Stat(gitPath)
		require.NoError(t, err)

		version, err := gitCmdFactory.GitVersion(ctx)
		require.NoError(t, err)
		require.Equal(t, "1.2.3", version.String())

		// We rewrite the file with the same content length and modification time such that
		// its file information doesn't change. As a result, information returned by
		// stat(3P) shouldn't differ and we should continue to see the cached version. This
		// is a known insufficiency, but it is extremely unlikely to ever happen in
		// production when the real Git binary changes.
		require.NoError(t, os.Remove(gitPath))
		testhelper.WriteExecutable(t, gitPath, []byte(generateVersionScript("git version 9.8.7")(git.ExecutionEnvironment{})))
		require.NoError(t, os.Chtimes(gitPath, stat.ModTime(), stat.ModTime()))

		// Given that we continue to use the cached version we shouldn't see any
		// change here.
		version, err = gitCmdFactory.GitVersion(ctx)
		require.NoError(t, err)
		require.Equal(t, "1.2.3", version.String())

		// If we really replace the Git binary with something else, then we should
		// see a changed version.
		require.NoError(t, os.Remove(gitPath))
		testhelper.WriteExecutable(t, gitPath, []byte(
			`#!/usr/bin/env bash
			echo 'git version 2.34.1'
		`))

		version, err = gitCmdFactory.GitVersion(ctx)
		require.NoError(t, err)
		require.Equal(t, "2.34.1", version.String())
	})
}

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

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

	// Create a repository and remove its gitconfig to bring us into a known state where there
	// is no repo-level configuration that interferes with our test.
	repo, repoDir := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{
		SkipCreationViaService: true,
	})
	require.NoError(t, os.Remove(filepath.Join(repoDir, "config")))

	expectedEnv := []string{
		"gc.auto=0",
		"core.autocrlf=input",
		"core.usereplacerefs=false",
		"core.fsync=objects,derived-metadata,reference",
		"core.fsyncmethod=fsync",
	}

	gitCmdFactory := gittest.NewCommandFactory(t, cfg)

	var stdout bytes.Buffer
	cmd, err := gitCmdFactory.New(ctx, repo, git.Command{
		Name: "config",
		Flags: []git.Option{
			git.Flag{Name: "--list"},
		},
	}, git.WithStdout(&stdout))
	require.NoError(t, err)

	require.NoError(t, cmd.Wait())
	require.Equal(t, expectedEnv, strings.Split(strings.TrimSpace(stdout.String()), "\n"))
}

// TestFsckConfiguration tests the hardcoded configuration of the
// git fsck subcommand generated through the command factory.
func TestFsckConfiguration(t *testing.T) {
	t.Parallel()

	for _, tc := range []struct {
		desc string
		data string
	}{
		{
			desc: "with valid commit",
			data: strings.Join([]string{
				"tree " + gittest.DefaultObjectHash.EmptyTreeOID.String(),
				"author " + gittest.DefaultCommitterSignature,
				"committer " + gittest.DefaultCommitterSignature,
				"",
				"message",
			}, "\n"),
		},
		{
			desc: "with missing space",
			data: strings.Join([]string{
				"tree " + gittest.DefaultObjectHash.EmptyTreeOID.String(),
				"author Scrooge McDuck <scrooge@mcduck.com>1659043074 -0500",
				"committer Scrooge McDuck <scrooge@mcduck.com>1659975573 -0500",
				"",
				"message",
			}, "\n"),
		},
		{
			desc: "with bad timezone",
			data: strings.Join([]string{
				"tree " + gittest.DefaultObjectHash.EmptyTreeOID.String(),
				"author Scrooge McDuck <scrooge@mcduck.com> 1659043074 -0500BAD",
				"committer Scrooge McDuck <scrooge@mcduck.com> 1659975573 -0500BAD",
				"",
				"message",
			}, "\n"),
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			ctx := testhelper.Context(t)
			cfg := testcfg.Build(t)
			repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg,
				gittest.CreateRepositoryConfig{SkipCreationViaService: true},
			)

			// Create commit object.
			commitOut := gittest.ExecOpts(t, cfg, gittest.ExecConfig{Stdin: bytes.NewBufferString(tc.data)},
				"-C", repoPath, "hash-object", "-w", "-t", "commit", "--stdin", "--literally",
			)
			_, err := gittest.DefaultObjectHash.FromHex(text.ChompBytes(commitOut))
			require.NoError(t, err)

			gitCmdFactory, cleanup, err := git.NewExecCommandFactory(cfg)
			require.NoError(t, err)
			defer cleanup()

			// Create fsck command with configured ignore rules options.
			cmd, err := gitCmdFactory.New(ctx, repoProto,
				git.Command{Name: "fsck"},
			)
			require.NoError(t, err)

			// Execute git fsck command.
			err = cmd.Wait()
			require.NoError(t, err)
		})
	}
}

type dummyHook struct {
	name    string
	handler func(context.Context, *trace2.Trace) error
}

func (h *dummyHook) Name() string {
	return h.name
}

func (h *dummyHook) Handle(ctx context.Context, trace *trace2.Trace) error {
	return h.handler(ctx, trace)
}

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

	extractEventNames := func(trace *trace2.Trace) []string {
		var names []string
		trace.Walk(testhelper.Context(t), func(ctx context.Context, trace *trace2.Trace) context.Context {
			names = append(names, trace.Name)
			return nil
		})
		return names
	}

	// Trace2 outputs differently between platforms. It may include irrelevant events. In some
	// rare cases, it can also re-order the events, leading to flaky tests. So, we assert the
	// presences of essential events of the tested Git command.
	essentialEvents := []string{
		"pack-objects:enumerate-objects",
		"pack-objects:prepare-pack",
		"pack-objects:write-pack-file",
		"data:pack-objects:write_pack_file/wrote",
	}

	for _, tc := range []struct {
		desc          string
		setup         func(t *testing.T) []trace2.Hook
		expectedStats map[string]any
		withStats     bool
	}{
		{
			desc: "trace2 hook runs successfully",
			setup: func(t *testing.T) []trace2.Hook {
				return []trace2.Hook{
					&dummyHook{
						name: "dummy",
						handler: func(ctx context.Context, trace *trace2.Trace) error {
							require.Subset(t, extractEventNames(trace), essentialEvents)
							return nil
						},
					},
				}
			},
			withStats: true,
			expectedStats: map[string]any{
				"trace2.activated": "true",
				"trace2.hooks":     "dummy",
			},
		},
		{
			desc: "multiple trace2 hooks run successfully",
			setup: func(t *testing.T) []trace2.Hook {
				return []trace2.Hook{
					&dummyHook{
						name: "dummy",
						handler: func(ctx context.Context, trace *trace2.Trace) error {
							require.Subset(t, extractEventNames(trace), essentialEvents)
							return nil
						},
					},
					&dummyHook{
						name: "dummy2",
						handler: func(ctx context.Context, trace *trace2.Trace) error {
							require.Subset(t, extractEventNames(trace), essentialEvents)
							return nil
						},
					},
				}
			},
			withStats: true,
			expectedStats: map[string]any{
				"trace2.activated": "true",
				"trace2.hooks":     "dummy,dummy2",
			},
		},
		{
			desc: "no hooks provided",
			setup: func(t *testing.T) []trace2.Hook {
				return []trace2.Hook{}
			},
			withStats: true,
			expectedStats: map[string]any{
				"trace2.activated": nil,
				"trace2.hooks":     nil,
			},
		},
		{
			desc: "trace2 hook returns error",
			setup: func(t *testing.T) []trace2.Hook {
				return []trace2.Hook{
					&dummyHook{
						name: "dummy",
						handler: func(ctx context.Context, trace *trace2.Trace) error {
							return fmt.Errorf("something goes wrong")
						},
					},
					&dummyHook{
						name: "dummy2",
						handler: func(ctx context.Context, trace *trace2.Trace) error {
							require.Fail(t, "should not trigger hook after prior one fails")
							return nil
						},
					},
				}
			},
			withStats: true,
			expectedStats: map[string]any{
				"trace2.activated": "true",
				"trace2.hooks":     "dummy,dummy2",
				"trace2.error":     `trace2: executing "dummy" handler: something goes wrong`,
			},
		},
		{
			desc: "stats is not initialized",
			setup: func(t *testing.T) []trace2.Hook {
				return []trace2.Hook{
					&dummyHook{
						name: "dummy",
						handler: func(ctx context.Context, trace *trace2.Trace) error {
							require.Subset(t, extractEventNames(trace), essentialEvents)
							return nil
						},
					},
				}
			},
			withStats: false,
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			hooks := tc.setup(t)
			ctx := testhelper.Context(t)
			if tc.withStats {
				ctx = command.InitContextStats(ctx)
			}

			performPackObjectGit(t, ctx, git.WithTrace2Hooks(hooks))

			if tc.withStats {
				stats := command.StatsFromContext(ctx)
				require.NotNil(t, stats)

				fields := stats.Fields()
				for key, value := range tc.expectedStats {
					require.Equal(t, value, fields[key])
				}
			} else {
				require.Nil(t, command.StatsFromContext(ctx))
			}
		})
	}
}

func TestTrace2TracingExporter(t *testing.T) {
	for _, tc := range []struct {
		desc          string
		tracerOptions []testhelper.StubTracingReporterOption
		setup         func(*testing.T, context.Context) context.Context
		assert        func(*testing.T, []string, map[string]any)
	}{
		{
			desc: "there is no active span",
			setup: func(t *testing.T, ctx context.Context) context.Context {
				return ctx
			},
			assert: func(t *testing.T, spans []string, statFields map[string]any) {
				require.NotContains(t, statFields, "trace2.activated")
				require.NotContains(t, statFields, "trace2.hooks")
				for _, span := range spans {
					require.NotEqual(t, "trace2.parse", span)
				}
			},
		},
		{
			desc: "active span is sampled",
			setup: func(t *testing.T, ctx context.Context) context.Context {
				_, ctx = tracing.StartSpan(ctx, "root", nil)
				return ctx
			},
			assert: func(t *testing.T, spans []string, statFields map[string]any) {
				require.Equal(t, statFields["trace2.activated"], "true")
				require.Equal(t, statFields["trace2.hooks"], "tracing_exporter")
				require.Subset(t, spans, []string{
					"git-rev-list",
					"git",
					"git:version",
					"git:start",
					"trace2.parse",
				})
			},
		},
		{
			desc:          "active span is not sampled",
			tracerOptions: []testhelper.StubTracingReporterOption{testhelper.NeverSampled()},
			setup: func(t *testing.T, ctx context.Context) context.Context {
				_, ctx = tracing.StartSpan(ctx, "root", nil)
				return ctx
			},
			assert: func(t *testing.T, spans []string, statFields map[string]any) {
				require.NotContains(t, statFields, "trace2.activated")
				require.NotContains(t, statFields, "trace2.hooks")
				for _, span := range spans {
					require.NotEqual(t, "trace2.parse", span)
				}
			},
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			reporter, cleanup := testhelper.StubTracingReporter(t, tc.tracerOptions...)
			defer cleanup()

			ctx := tc.setup(t, command.InitContextStats(testhelper.Context(t)))
			performRevList(t, ctx)

			stats := command.StatsFromContext(ctx)
			require.NotNil(t, stats)
			statFields := stats.Fields()

			var spans []string
			for _, span := range testhelper.ReportedSpans(t, reporter) {
				spans = append(spans, span.Operation)
			}

			tc.assert(t, spans, statFields)
		})
	}
}

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

	for _, tc := range []struct {
		desc              string
		performGitCommand func(t *testing.T, ctx context.Context, opts ...git.ExecCommandFactoryOption)
		assert            func(*testing.T, context.Context, logrus.Fields)
	}{
		{
			desc:              "git-pack-objects",
			performGitCommand: performPackObjectGit,
			assert: func(t *testing.T, ctx context.Context, statFields logrus.Fields) {
				require.Equal(t, "true", statFields["trace2.activated"])
				require.Equal(t, "pack_objects_metrics", statFields["trace2.hooks"])
				require.Contains(t, statFields, "pack_objects.enumerate_objects_ms")
				require.Contains(t, statFields, "pack_objects.prepare_pack_ms")
				require.Contains(t, statFields, "pack_objects.write_pack_file_ms")
				require.Equal(t, 1, statFields["pack_objects.written_object_count"])
			},
		},
		{
			desc: "other git command",
			performGitCommand: func(t *testing.T, ctx context.Context, opts ...git.ExecCommandFactoryOption) {
				cfg := testcfg.Build(t)
				repoProto, _ := gittest.CreateRepository(t, ctx, cfg,
					gittest.CreateRepositoryConfig{SkipCreationViaService: true},
				)
				gitCmdFactory, cleanup, err := git.NewExecCommandFactory(cfg, opts...)
				require.NoError(t, err)
				defer cleanup()

				cmd, err := gitCmdFactory.New(ctx, repoProto, git.Command{
					Name: "rev-list",
					Flags: []git.Option{
						git.Flag{Name: "--all"},
						git.Flag{Name: "--max-count=1"},
					},
				})
				require.NoError(t, err)

				err = cmd.Wait()
				require.NoError(t, err)
			},
			assert: func(t *testing.T, ctx context.Context, statFields logrus.Fields) {
				require.NotContains(t, statFields, "trace2.activated")
				require.NotContains(t, statFields, "trace2.hooks")
			},
		},
	} {
		tc := tc
		t.Run(tc.desc, func(t *testing.T) {
			t.Parallel()

			ctx := command.InitContextStats(testhelper.Context(t))
			tc.performGitCommand(t, ctx)

			stats := command.StatsFromContext(ctx)
			require.NotNil(t, stats)

			statFields := stats.Fields()
			tc.assert(t, ctx, statFields)
		})
	}
}

// This test modifies global tracing tracer. Thus, it cannot run in parallel.
func TestDefaultTrace2HooksFor(t *testing.T) {
	for _, tc := range []struct {
		desc          string
		subCmd        string
		setup         func(t *testing.T) (context.Context, []trace2.Hook)
		tracerOptions []testhelper.StubTracingReporterOption
	}{
		{
			desc:   "there is no active span",
			subCmd: "status",
			setup: func(t *testing.T) (context.Context, []trace2.Hook) {
				ctx := testhelper.Context(t)
				return ctx, []trace2.Hook{}
			},
		},
		{
			desc:   "active span is sampled",
			subCmd: "status",
			setup: func(t *testing.T) (context.Context, []trace2.Hook) {
				_, ctx := tracing.StartSpan(testhelper.Context(t), "root", nil)
				return ctx, []trace2.Hook{
					trace2hooks.NewTracingExporter(),
				}
			},
		},
		{
			desc:   "active span is not sampled",
			subCmd: "status",
			setup: func(t *testing.T) (context.Context, []trace2.Hook) {
				_, ctx := tracing.StartSpan(testhelper.Context(t), "root", nil)
				return ctx, []trace2.Hook{}
			},
			tracerOptions: []testhelper.StubTracingReporterOption{testhelper.NeverSampled()},
		},
		{
			desc:   "subcmd is pack-objects but span is not sampled",
			subCmd: "pack-objects",
			setup: func(t *testing.T) (context.Context, []trace2.Hook) {
				return testhelper.Context(t), []trace2.Hook{
					trace2hooks.NewPackObjectsMetrics(),
				}
			},
		},
		{
			desc:   "subcmd is pack-objects and active span is sampled",
			subCmd: "pack-objects",
			setup: func(t *testing.T) (context.Context, []trace2.Hook) {
				_, ctx := tracing.StartSpan(testhelper.Context(t), "root", nil)
				hooks := []trace2.Hook{
					trace2hooks.NewTracingExporter(),
					trace2hooks.NewPackObjectsMetrics(),
				}

				return ctx, hooks
			},
		},
	} {
		t.Run(tc.desc, func(t *testing.T) {
			_, cleanup := testhelper.StubTracingReporter(t, tc.tracerOptions...)
			defer cleanup()

			ctx, expectedHooks := tc.setup(t)
			hooks := git.DefaultTrace2HooksFor(ctx, tc.subCmd)

			require.Equal(t, hookNames(expectedHooks), hookNames(hooks))
		})
	}
}

func performPackObjectGit(t *testing.T, ctx context.Context, opts ...git.ExecCommandFactoryOption) {
	cfg := testcfg.Build(t)
	repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg,
		gittest.CreateRepositoryConfig{SkipCreationViaService: true},
	)

	var input bytes.Buffer
	for i := 0; i <= 10; i++ {
		input.WriteString(gittest.WriteCommit(t, cfg, repoPath).String())
		input.WriteString("\n")
	}

	gitCmdFactory, cleanup, err := git.NewExecCommandFactory(cfg, opts...)
	require.NoError(t, err)
	defer cleanup()

	cmd, err := gitCmdFactory.New(ctx, repoProto, git.Command{
		Name: "pack-objects",
		Flags: []git.Option{
			git.Flag{Name: "--compression=0"},
			git.Flag{Name: "--stdout"},
			git.Flag{Name: "-q"},
		},
	}, git.WithStdin(&input))
	require.NoError(t, err)

	err = cmd.Wait()
	require.NoError(t, err)
}

func performRevList(t *testing.T, ctx context.Context, opts ...git.ExecCommandFactoryOption) {
	cfg := testcfg.Build(t)
	repoProto, _ := gittest.CreateRepository(t, ctx, cfg,
		gittest.CreateRepositoryConfig{SkipCreationViaService: true},
	)
	gitCmdFactory, cleanup, err := git.NewExecCommandFactory(cfg, opts...)
	require.NoError(t, err)
	defer cleanup()

	cmd, err := gitCmdFactory.New(ctx, repoProto, git.Command{
		Name: "rev-list",
		Flags: []git.Option{
			git.Flag{Name: "--max-count=10"},
			git.Flag{Name: "--all"},
		},
	})
	require.NoError(t, err)

	err = cmd.Wait()
	require.NoError(t, err)
}

func hookNames(hooks []trace2.Hook) []string {
	var names []string
	for _, hook := range hooks {
		names = append(names, hook.Name())
	}
	return names
}
