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 }