github.com/nektos/act@v0.2.63/pkg/common/git/git.go (about) 1 package git 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "io" 8 "os" 9 "path" 10 "regexp" 11 "strings" 12 "sync" 13 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/config" 16 "github.com/go-git/go-git/v5/plumbing" 17 "github.com/go-git/go-git/v5/plumbing/storer" 18 "github.com/go-git/go-git/v5/plumbing/transport/http" 19 "github.com/mattn/go-isatty" 20 log "github.com/sirupsen/logrus" 21 22 "github.com/nektos/act/pkg/common" 23 ) 24 25 var ( 26 codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`) 27 codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`) 28 githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`) 29 githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`) 30 31 cloneLock sync.Mutex 32 33 ErrShortRef = errors.New("short SHA references are not supported") 34 ErrNoRepo = errors.New("unable to find git repo") 35 ) 36 37 type Error struct { 38 err error 39 commit string 40 } 41 42 func (e *Error) Error() string { 43 return e.err.Error() 44 } 45 46 func (e *Error) Unwrap() error { 47 return e.err 48 } 49 50 func (e *Error) Commit() string { 51 return e.commit 52 } 53 54 // FindGitRevision get the current git revision 55 func FindGitRevision(ctx context.Context, file string) (shortSha string, sha string, err error) { 56 logger := common.Logger(ctx) 57 58 gitDir, err := git.PlainOpenWithOptions( 59 file, 60 &git.PlainOpenOptions{ 61 DetectDotGit: true, 62 EnableDotGitCommonDir: true, 63 }, 64 ) 65 66 if err != nil { 67 logger.WithError(err).Error("path", file, "not located inside a git repository") 68 return "", "", err 69 } 70 71 head, err := gitDir.Reference(plumbing.HEAD, true) 72 if err != nil { 73 return "", "", err 74 } 75 76 if head.Hash().IsZero() { 77 return "", "", fmt.Errorf("HEAD sha1 could not be resolved") 78 } 79 80 hash := head.Hash().String() 81 82 logger.Debugf("Found revision: %s", hash) 83 return hash[:7], strings.TrimSpace(hash), nil 84 } 85 86 // FindGitRef get the current git ref 87 func FindGitRef(ctx context.Context, file string) (string, error) { 88 logger := common.Logger(ctx) 89 90 logger.Debugf("Loading revision from git directory") 91 _, ref, err := FindGitRevision(ctx, file) 92 if err != nil { 93 return "", err 94 } 95 96 logger.Debugf("HEAD points to '%s'", ref) 97 98 // Prefer the git library to iterate over the references and find a matching tag or branch. 99 var refTag = "" 100 var refBranch = "" 101 repo, err := git.PlainOpenWithOptions( 102 file, 103 &git.PlainOpenOptions{ 104 DetectDotGit: true, 105 EnableDotGitCommonDir: true, 106 }, 107 ) 108 109 if err != nil { 110 return "", err 111 } 112 113 iter, err := repo.References() 114 if err != nil { 115 return "", err 116 } 117 118 // find the reference that matches the revision's has 119 err = iter.ForEach(func(r *plumbing.Reference) error { 120 /* tags and branches will have the same hash 121 * when a user checks out a tag, it is not mentioned explicitly 122 * in the go-git package, we must identify the revision 123 * then check if any tag matches that revision, 124 * if so then we checked out a tag 125 * else we look for branches and if matches, 126 * it means we checked out a branch 127 * 128 * If a branches matches first we must continue and check all tags (all references) 129 * in case we match with a tag later in the iteration 130 */ 131 if r.Hash().String() == ref { 132 if r.Name().IsTag() { 133 refTag = r.Name().String() 134 } 135 if r.Name().IsBranch() { 136 refBranch = r.Name().String() 137 } 138 } 139 140 // we found what we where looking for 141 if refTag != "" && refBranch != "" { 142 return storer.ErrStop 143 } 144 145 return nil 146 }) 147 148 if err != nil { 149 return "", err 150 } 151 152 // order matters here see above comment. 153 if refTag != "" { 154 return refTag, nil 155 } 156 if refBranch != "" { 157 return refBranch, nil 158 } 159 160 return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref) 161 } 162 163 // FindGithubRepo get the repo 164 func FindGithubRepo(ctx context.Context, file, githubInstance, remoteName string) (string, error) { 165 if remoteName == "" { 166 remoteName = "origin" 167 } 168 169 url, err := findGitRemoteURL(ctx, file, remoteName) 170 if err != nil { 171 return "", err 172 } 173 _, slug, err := findGitSlug(url, githubInstance) 174 return slug, err 175 } 176 177 func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) { 178 repo, err := git.PlainOpenWithOptions( 179 file, 180 &git.PlainOpenOptions{ 181 DetectDotGit: true, 182 EnableDotGitCommonDir: true, 183 }, 184 ) 185 if err != nil { 186 return "", err 187 } 188 189 remote, err := repo.Remote(remoteName) 190 if err != nil { 191 return "", err 192 } 193 194 if len(remote.Config().URLs) < 1 { 195 return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName) 196 } 197 198 return remote.Config().URLs[0], nil 199 } 200 201 func findGitSlug(url string, githubInstance string) (string, string, error) { 202 if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil { 203 return "CodeCommit", matches[2], nil 204 } else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil { 205 return "CodeCommit", matches[2], nil 206 } else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil { 207 return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil 208 } else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil { 209 return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil 210 } else if githubInstance != "github.com" { 211 gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance)) 212 gheSSHRegex := regexp.MustCompile(fmt.Sprintf(`%s[:/](.+)/(.+?)(?:.git)?$`, githubInstance)) 213 if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil { 214 return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil 215 } else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil { 216 return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2]), nil 217 } 218 } 219 return "", url, nil 220 } 221 222 // NewGitCloneExecutorInput the input for the NewGitCloneExecutor 223 type NewGitCloneExecutorInput struct { 224 URL string 225 Ref string 226 Dir string 227 Token string 228 OfflineMode bool 229 } 230 231 // CloneIfRequired ... 232 func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) { 233 // If the remote URL has changed, remove the directory and clone again. 234 if r, err := git.PlainOpen(input.Dir); err == nil { 235 if remote, err := r.Remote("origin"); err == nil { 236 if len(remote.Config().URLs) > 0 && remote.Config().URLs[0] != input.URL { 237 _ = os.RemoveAll(input.Dir) 238 } 239 } 240 } 241 242 r, err := git.PlainOpen(input.Dir) 243 if err != nil { 244 var progressWriter io.Writer 245 if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { 246 if entry, ok := logger.(*log.Entry); ok { 247 progressWriter = entry.WriterLevel(log.DebugLevel) 248 } else if lgr, ok := logger.(*log.Logger); ok { 249 progressWriter = lgr.WriterLevel(log.DebugLevel) 250 } else { 251 log.Errorf("Unable to get writer from logger (type=%T)", logger) 252 progressWriter = os.Stdout 253 } 254 } 255 256 cloneOptions := git.CloneOptions{ 257 URL: input.URL, 258 Progress: progressWriter, 259 } 260 if input.Token != "" { 261 cloneOptions.Auth = &http.BasicAuth{ 262 Username: "token", 263 Password: input.Token, 264 } 265 } 266 267 r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions) 268 if err != nil { 269 logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err) 270 return nil, err 271 } 272 273 if err = os.Chmod(input.Dir, 0o755); err != nil { 274 return nil, err 275 } 276 } 277 278 return r, nil 279 } 280 281 func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) { 282 fetchOptions.RefSpecs = []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD"} 283 pullOptions.Force = true 284 285 if token != "" { 286 auth := &http.BasicAuth{ 287 Username: "token", 288 Password: token, 289 } 290 fetchOptions.Auth = auth 291 pullOptions.Auth = auth 292 } 293 294 return fetchOptions, pullOptions 295 } 296 297 // NewGitCloneExecutor creates an executor to clone git repos 298 // 299 //nolint:gocyclo 300 func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { 301 return func(ctx context.Context) error { 302 logger := common.Logger(ctx) 303 logger.Infof(" \u2601 git clone '%s' # ref=%s", input.URL, input.Ref) 304 logger.Debugf(" cloning %s to %s", input.URL, input.Dir) 305 306 cloneLock.Lock() 307 defer cloneLock.Unlock() 308 309 refName := plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", input.Ref)) 310 r, err := CloneIfRequired(ctx, refName, input, logger) 311 if err != nil { 312 return err 313 } 314 315 isOfflineMode := input.OfflineMode 316 317 // fetch latest changes 318 fetchOptions, pullOptions := gitOptions(input.Token) 319 320 if !isOfflineMode { 321 err = r.Fetch(&fetchOptions) 322 if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { 323 return err 324 } 325 } 326 327 var hash *plumbing.Hash 328 rev := plumbing.Revision(input.Ref) 329 if hash, err = r.ResolveRevision(rev); err != nil { 330 logger.Errorf("Unable to resolve %s: %v", input.Ref, err) 331 } 332 333 if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) { 334 return &Error{ 335 err: ErrShortRef, 336 commit: hash.String(), 337 } 338 } 339 340 // At this point we need to know if it's a tag or a branch 341 // And the easiest way to do it is duck typing 342 // 343 // If err is nil, it's a tag so let's proceed with that hash like we would if 344 // it was a sha 345 refType := "tag" 346 rev = plumbing.Revision(path.Join("refs", "tags", input.Ref)) 347 if _, err := r.Tag(input.Ref); errors.Is(err, git.ErrTagNotFound) { 348 rName := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref)) 349 if _, err := r.Reference(rName, false); errors.Is(err, plumbing.ErrReferenceNotFound) { 350 refType = "sha" 351 rev = plumbing.Revision(input.Ref) 352 } else { 353 refType = "branch" 354 rev = plumbing.Revision(rName) 355 } 356 } 357 358 if hash, err = r.ResolveRevision(rev); err != nil { 359 logger.Errorf("Unable to resolve %s: %v", input.Ref, err) 360 return err 361 } 362 363 var w *git.Worktree 364 if w, err = r.Worktree(); err != nil { 365 return err 366 } 367 368 // If the hash resolved doesn't match the ref provided in a workflow then we're 369 // using a branch or tag ref, not a sha 370 // 371 // Repos on disk point to commit hashes, and need to checkout input.Ref before 372 // we try and pull down any changes 373 if hash.String() != input.Ref && refType == "branch" { 374 logger.Debugf("Provided ref is not a sha. Checking out branch before pulling changes") 375 sourceRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", input.Ref)) 376 if err = w.Checkout(&git.CheckoutOptions{ 377 Branch: sourceRef, 378 Force: true, 379 }); err != nil { 380 logger.Errorf("Unable to checkout %s: %v", sourceRef, err) 381 return err 382 } 383 } 384 if !isOfflineMode { 385 if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate { 386 logger.Debugf("Unable to pull %s: %v", refName, err) 387 } 388 } 389 logger.Debugf("Cloned %s to %s", input.URL, input.Dir) 390 391 if hash.String() != input.Ref && refType == "branch" { 392 logger.Debugf("Provided ref is not a sha. Updating branch ref after pull") 393 if hash, err = r.ResolveRevision(rev); err != nil { 394 logger.Errorf("Unable to resolve %s: %v", input.Ref, err) 395 return err 396 } 397 } 398 if err = w.Checkout(&git.CheckoutOptions{ 399 Hash: *hash, 400 Force: true, 401 }); err != nil { 402 logger.Errorf("Unable to checkout %s: %v", *hash, err) 403 return err 404 } 405 406 if err = w.Reset(&git.ResetOptions{ 407 Mode: git.HardReset, 408 Commit: *hash, 409 }); err != nil { 410 logger.Errorf("Unable to reset to %s: %v", hash.String(), err) 411 return err 412 } 413 414 logger.Debugf("Checked out %s", input.Ref) 415 return nil 416 } 417 }