github.com/gitbundle/modules@v0.0.0-20231025071548-85b91c5c3b01/git/git.go (about)

     1  // Copyright 2023 The GitBundle Inc. All rights reserved.
     2  // Copyright 2017 The Gitea Authors. All rights reserved.
     3  // Use of this source code is governed by a MIT-style
     4  // license that can be found in the LICENSE file.
     5  
     6  // Copyright 2015 The Gogs Authors. All rights reserved.
     7  
     8  package git
     9  
    10  import (
    11  	"context"
    12  	"errors"
    13  	"fmt"
    14  	"os"
    15  	"os/exec"
    16  	"path/filepath"
    17  	"regexp"
    18  	"runtime"
    19  	"strings"
    20  	"sync"
    21  	"time"
    22  
    23  	"github.com/gitbundle/modules/log"
    24  	"github.com/gitbundle/modules/setting"
    25  
    26  	"github.com/hashicorp/go-version"
    27  )
    28  
    29  // GitVersionRequired is the minimum Git version required
    30  const GitVersionRequired = "2.0.0"
    31  
    32  var (
    33  	// GitExecutable is the command name of git
    34  	// Could be updated to an absolute path while initialization
    35  	GitExecutable = "git"
    36  
    37  	// DefaultContext is the default context to run git commands in, must be initialized by git.InitXxx
    38  	DefaultContext context.Context
    39  
    40  	// SupportProcReceive version >= 2.29.0
    41  	SupportProcReceive bool
    42  
    43  	gitVersion *version.Version
    44  )
    45  
    46  // loadGitVersion returns current Git version from shell. Internal usage only.
    47  func loadGitVersion() (*version.Version, error) {
    48  	// doesn't need RWMutex because its exec by Init()
    49  	if gitVersion != nil {
    50  		return gitVersion, nil
    51  	}
    52  
    53  	stdout, _, runErr := NewCommand(DefaultContext, "version").RunStdString(nil)
    54  	if runErr != nil {
    55  		return nil, runErr
    56  	}
    57  
    58  	fields := strings.Fields(stdout)
    59  	if len(fields) < 3 {
    60  		return nil, fmt.Errorf("invalid git version output: %s", stdout)
    61  	}
    62  
    63  	var versionString string
    64  
    65  	// Handle special case on Windows.
    66  	i := strings.Index(fields[2], "windows")
    67  	if i >= 1 {
    68  		versionString = fields[2][:i-1]
    69  	} else {
    70  		versionString = fields[2]
    71  	}
    72  
    73  	var err error
    74  	gitVersion, err = version.NewVersion(versionString)
    75  	return gitVersion, err
    76  }
    77  
    78  // SetExecutablePath changes the path of git executable and checks the file permission and version.
    79  func SetExecutablePath(path string) error {
    80  	// If path is empty, we use the default value of GitExecutable "git" to search for the location of git.
    81  	if path != "" {
    82  		GitExecutable = path
    83  	}
    84  	absPath, err := exec.LookPath(GitExecutable)
    85  	if err != nil {
    86  		return fmt.Errorf("git not found: %w", err)
    87  	}
    88  	GitExecutable = absPath
    89  
    90  	_, err = loadGitVersion()
    91  	if err != nil {
    92  		return fmt.Errorf("unable to load git version: %w", err)
    93  	}
    94  
    95  	versionRequired, err := version.NewVersion(GitVersionRequired)
    96  	if err != nil {
    97  		return err
    98  	}
    99  
   100  	if gitVersion.LessThan(versionRequired) {
   101  		moreHint := "get git: https://git-scm.com/download/"
   102  		if runtime.GOOS == "linux" {
   103  			// there are a lot of CentOS/RHEL users using old git, so we add a special hint for them
   104  			if _, err = os.Stat("/etc/redhat-release"); err == nil {
   105  				// ius.io is the recommended official(git-scm.com) method to install git
   106  				moreHint = "get git: https://git-scm.com/download/linux and https://ius.io"
   107  			}
   108  		}
   109  		return fmt.Errorf("installed git version %q is not supported, GitBundle requires git version >= %q, %s", gitVersion.Original(), GitVersionRequired, moreHint)
   110  	}
   111  
   112  	return nil
   113  }
   114  
   115  // VersionInfo returns git version information
   116  func VersionInfo() string {
   117  	if gitVersion == nil {
   118  		return "(git not found)"
   119  	}
   120  	format := "%s"
   121  	args := []interface{}{gitVersion.Original()}
   122  	// Since git wire protocol has been released from git v2.18
   123  	if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
   124  		format += ", Wire Protocol %s Enabled"
   125  		args = append(args, "Version 2") // for focus color
   126  	}
   127  
   128  	return fmt.Sprintf(format, args...)
   129  }
   130  
   131  func checkInit() error {
   132  	if setting.Git.HomePath == "" {
   133  		return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
   134  	}
   135  	if DefaultContext != nil {
   136  		log.Warn("git module has been initialized already, duplicate init should be fixed")
   137  	}
   138  	return nil
   139  }
   140  
   141  // HomeDir is the home dir for git to store the global config file used by GitBundle internally
   142  func HomeDir() string {
   143  	if setting.Git.HomePath == "" {
   144  		// strict check, make sure the git module is initialized correctly.
   145  		// attention: when the git module is called in gitbundle sub-command (serv/hook), the log module is not able to show messages to users.
   146  		// for example: if there is gitbundle git hook code calling git.NewCommand before git.InitXxx, the integration test won't show the real failure reasons.
   147  		log.Fatal("Unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
   148  		return ""
   149  	}
   150  	return setting.Git.HomePath
   151  }
   152  
   153  // InitSimple initializes git module with a very simple step, no config changes, no global command arguments.
   154  // This method doesn't change anything to filesystem. At the moment, it is only used by "git serv" sub-command, no data-race
   155  // However, in integration test, the sub-command function may be called in the current process, so the InitSimple would be called multiple times, too
   156  func InitSimple(ctx context.Context) error {
   157  	if err := checkInit(); err != nil {
   158  		return err
   159  	}
   160  
   161  	DefaultContext = ctx
   162  
   163  	if setting.Git.Timeout.Default > 0 {
   164  		defaultCommandExecutionTimeout = time.Duration(setting.Git.Timeout.Default) * time.Second
   165  	}
   166  
   167  	return SetExecutablePath(setting.Git.Path)
   168  }
   169  
   170  var initOnce sync.Once
   171  
   172  // InitOnceWithSync initializes git module with version check and change global variables, sync gitconfig.
   173  // This method will update the global variables ONLY ONCE (just like git.CheckLFSVersion -- which is not ideal too),
   174  // otherwise there will be data-race problem at the moment.
   175  func InitOnceWithSync(ctx context.Context) (err error) {
   176  	if err = checkInit(); err != nil {
   177  		return err
   178  	}
   179  
   180  	initOnce.Do(func() {
   181  		if err = InitSimple(ctx); err != nil {
   182  			return
   183  		}
   184  
   185  		// when git works with gnupg (commit signing), there should be a stable home for gnupg commands
   186  		if _, ok := os.LookupEnv("GNUPGHOME"); !ok {
   187  			_ = os.Setenv("GNUPGHOME", filepath.Join(HomeDir(), ".gnupg"))
   188  		}
   189  
   190  		// Since git wire protocol has been released from git v2.18
   191  		if setting.Git.EnableAutoGitWireProtocol && CheckGitVersionAtLeast("2.18") == nil {
   192  			globalCommandArgs = append(globalCommandArgs, "-c", "protocol.version=2")
   193  		}
   194  
   195  		// By default partial clones are disabled, enable them from git v2.22
   196  		if !setting.Git.DisablePartialClone && CheckGitVersionAtLeast("2.22") == nil {
   197  			globalCommandArgs = append(globalCommandArgs, "-c", "uploadpack.allowfilter=true", "-c", "uploadpack.allowAnySHA1InWant=true")
   198  		}
   199  
   200  		// Explicitly disable credential helper, otherwise Git credentials might leak
   201  		if CheckGitVersionAtLeast("2.9") == nil {
   202  			globalCommandArgs = append(globalCommandArgs, "-c", "credential.helper=")
   203  		}
   204  
   205  		SupportProcReceive = CheckGitVersionAtLeast("2.29") == nil
   206  	})
   207  	if err != nil {
   208  		return err
   209  	}
   210  	return syncGitConfig()
   211  }
   212  
   213  // syncGitConfig only modifies gitconfig, won't change global variables (otherwise there will be data-race problem)
   214  func syncGitConfig() (err error) {
   215  	if err = os.MkdirAll(HomeDir(), os.ModePerm); err != nil {
   216  		return fmt.Errorf("unable to prepare git home directory %s, err: %w", HomeDir(), err)
   217  	}
   218  
   219  	// Git requires setting user.name and user.email in order to commit changes - old comment: "if they're not set just add some defaults"
   220  	// TODO: need to confirm whether users really need to change these values manually. It seems that these values are dummy only and not really used.
   221  	// If these values are not really used, then they can be set (overwritten) directly without considering about existence.
   222  	for configKey, defaultValue := range map[string]string{
   223  		"user.name":  "GitBundle",
   224  		"user.email": "gitbundle@fake.local",
   225  	} {
   226  		if err := configSetNonExist(configKey, defaultValue); err != nil {
   227  			return err
   228  		}
   229  	}
   230  
   231  	// Set git some configurations - these must be set to these values for gitbundle to work correctly
   232  	if err := configSet("core.quotePath", "false"); err != nil {
   233  		return err
   234  	}
   235  
   236  	if CheckGitVersionAtLeast("2.10") == nil {
   237  		if err := configSet("receive.advertisePushOptions", "true"); err != nil {
   238  			return err
   239  		}
   240  	}
   241  
   242  	if CheckGitVersionAtLeast("2.18") == nil {
   243  		if err := configSet("core.commitGraph", "true"); err != nil {
   244  			return err
   245  		}
   246  		if err := configSet("gc.writeCommitGraph", "true"); err != nil {
   247  			return err
   248  		}
   249  		if err := configSet("fetch.writeCommitGraph", "true"); err != nil {
   250  			return err
   251  		}
   252  	}
   253  
   254  	if SupportProcReceive {
   255  		// set support for AGit flow
   256  		if err := configAddNonExist("receive.procReceiveRefs", "refs/for"); err != nil {
   257  			return err
   258  		}
   259  	} else {
   260  		if err := configUnsetAll("receive.procReceiveRefs", "refs/for"); err != nil {
   261  			return err
   262  		}
   263  	}
   264  
   265  	// Due to CVE-2022-24765, git now denies access to git directories which are not owned by current user
   266  	// however, some docker users and samba users find it difficult to configure their systems so that GitBundle's git repositories are owned by the GitBundle user. (Possibly Windows Service users - but ownership in this case should really be set correctly on the filesystem.)
   267  	// see issue: https://github.com/go-gitea/gitea/issues/19455
   268  	// Fundamentally the problem lies with the uid-gid-mapping mechanism for filesystems in docker on windows (and to a lesser extent samba).
   269  	// Docker's configuration mechanism for local filesystems provides no way of setting this mapping and although there is a mechanism for setting this uid through using cifs mounting it is complicated and essentially undocumented
   270  	// Thus the owner uid/gid for files on these filesystems will be marked as root.
   271  	// As GitBundle now always use its internal git config file, and access to the git repositories is managed through GitBundle,
   272  	// it is now safe to set "safe.directory=*" for internal usage only.
   273  	// Please note: the wildcard "*" is only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later
   274  	// Although only supported by Git 2.30.4/2.31.3/2.32.2/2.33.3/2.34.3/2.35.3/2.36 and later - this setting is tolerated by earlier versions
   275  	if err := configAddNonExist("safe.directory", "*"); err != nil {
   276  		return err
   277  	}
   278  	if runtime.GOOS == "windows" {
   279  		if err := configSet("core.longpaths", "true"); err != nil {
   280  			return err
   281  		}
   282  		if setting.Git.DisableCoreProtectNTFS {
   283  			err = configSet("core.protectNTFS", "false")
   284  		} else {
   285  			err = configUnsetAll("core.protectNTFS", "false")
   286  		}
   287  		if err != nil {
   288  			return err
   289  		}
   290  	}
   291  
   292  	return nil
   293  }
   294  
   295  // CheckGitVersionAtLeast check git version is at least the constraint version
   296  func CheckGitVersionAtLeast(atLeast string) error {
   297  	if _, err := loadGitVersion(); err != nil {
   298  		return err
   299  	}
   300  	atLeastVersion, err := version.NewVersion(atLeast)
   301  	if err != nil {
   302  		return err
   303  	}
   304  	if gitVersion.Compare(atLeastVersion) < 0 {
   305  		return fmt.Errorf("installed git binary version %s is not at least %s", gitVersion.Original(), atLeast)
   306  	}
   307  	return nil
   308  }
   309  
   310  func configSet(key, value string) error {
   311  	stdout, _, err := NewCommand(DefaultContext, "config", "--get", key).RunStdString(nil)
   312  	if err != nil && !err.IsExitCode(1) {
   313  		return fmt.Errorf("failed to get git config %s, err: %w", key, err)
   314  	}
   315  
   316  	currValue := strings.TrimSpace(stdout)
   317  	if currValue == value {
   318  		return nil
   319  	}
   320  
   321  	_, _, err = NewCommand(DefaultContext, "config", "--global", key, value).RunStdString(nil)
   322  	if err != nil {
   323  		return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
   324  	}
   325  
   326  	return nil
   327  }
   328  
   329  func configSetNonExist(key, value string) error {
   330  	_, _, err := NewCommand(DefaultContext, "config", "--get", key).RunStdString(nil)
   331  	if err == nil {
   332  		// already exist
   333  		return nil
   334  	}
   335  	if err.IsExitCode(1) {
   336  		// not exist, set new config
   337  		_, _, err = NewCommand(DefaultContext, "config", "--global", key, value).RunStdString(nil)
   338  		if err != nil {
   339  			return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
   340  		}
   341  		return nil
   342  	}
   343  
   344  	return fmt.Errorf("failed to get git config %s, err: %w", key, err)
   345  }
   346  
   347  func configAddNonExist(key, value string) error {
   348  	_, _, err := NewCommand(DefaultContext, "config", "--get", key, regexp.QuoteMeta(value)).RunStdString(nil)
   349  	if err == nil {
   350  		// already exist
   351  		return nil
   352  	}
   353  	if err.IsExitCode(1) {
   354  		// not exist, add new config
   355  		_, _, err = NewCommand(DefaultContext, "config", "--global", "--add", key, value).RunStdString(nil)
   356  		if err != nil {
   357  			return fmt.Errorf("failed to add git global config %s, err: %w", key, err)
   358  		}
   359  		return nil
   360  	}
   361  	return fmt.Errorf("failed to get git config %s, err: %w", key, err)
   362  }
   363  
   364  func configUnsetAll(key, value string) error {
   365  	_, _, err := NewCommand(DefaultContext, "config", "--get", key).RunStdString(nil)
   366  	if err == nil {
   367  		// exist, need to remove
   368  		_, _, err = NewCommand(DefaultContext, "config", "--global", "--unset-all", key, regexp.QuoteMeta(value)).RunStdString(nil)
   369  		if err != nil {
   370  			return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
   371  		}
   372  		return nil
   373  	}
   374  	if err.IsExitCode(1) {
   375  		// not exist
   376  		return nil
   377  	}
   378  	return fmt.Errorf("failed to get git config %s, err: %w", key, err)
   379  }
   380  
   381  // Fsck verifies the connectivity and validity of the objects in the database
   382  func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args ...string) error {
   383  	return NewCommand(ctx, "fsck").AddArguments(args...).Run(&RunOpts{Timeout: timeout, Dir: repoPath})
   384  }