package git import ( "encoding/hex" "encoding/json" "errors" "fmt" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/google/go-github/v41/github" "github.com/synapsecns/sanguine/contrib/git-changes-action/detector/actionscore" "github.com/synapsecns/sanguine/contrib/git-changes-action/detector/tree" "github.com/synapsecns/sanguine/core" "os" "strings" ) // getChangeTreeFromGit returns a tree of all the files that have changed between the current commit and the commit with the given hash. // nolint: cyclop, gocognit func getChangeTreeFromGit(repoPath string, ghContext *actionscore.Context, head, base string) (tree.Tree, error) { // open the repository repository, err := git.PlainOpen(repoPath) if err != nil { return nil, fmt.Errorf("could not open repository %s: %w", repoPath, err) } head, err = GetHead(repository, ghContext, head) if err != nil { return nil, fmt.Errorf("could not get head: %w", err) } head = getShortName(head) base = getShortName(getBase(ghContext, base)) _, err = hex.DecodeString(base) isBaseSha := err == nil isBaseSameAsHead := base == head var baseSha string // If base is commit SHA we will do comparison against the referenced commit // Or if base references same branch it was pushed to, we will do comparison against the previously pushed commit //nolint: nestif if isBaseSha || isBaseSameAsHead { if !isBaseSha { var ok bool baseSha, head, ok, _ = tryGetPushEvent() isBaseSha = true if !ok { baseSha, head, err = getHeadBase(repository) if err != nil { // TODO: we might need to add error handling here for last commit on a branch, see: https://github.com/dorny/paths-filter/blob/master/src/main.ts#L141 return nil, fmt.Errorf("could not get last commit hash: %w", err) } } } } if head == "" { rawHead, err := repository.Head() if err != nil { return nil, fmt.Errorf("could not get HEAD: %w", err) } head = rawHead.String() } // nolint: nestif if !isBaseSha { res, err := convertToSha(repository, base) if err != nil { return nil, fmt.Errorf("could not convert base to sha: %w", err) } baseSha = res.String() } // create the change tree changeTree := tree.NewTree() // get each commit object (before and after) baseObject, err := repository.CommitObject(plumbing.NewHash(baseSha)) if err != nil { return nil, fmt.Errorf("could not get commit object for base %s: %w", baseSha, err) } headHash, err := convertToSha(repository, head) if err != nil { return nil, fmt.Errorf("could not convert head to sha: %w", err) } headObject, err := repository.CommitObject(*headHash) if err != nil { return nil, fmt.Errorf("could not get commit object for head %s: %w", head, err) } diff, err := fastDiff(baseObject, headObject) if err != nil { return nil, fmt.Errorf("could not get diff: %w", err) } changeTree.Add(diff...) return changeTree, nil } func convertToSha(repository *git.Repository, ref string) (res *plumbing.Hash, err error) { // already a sha _, err = hex.DecodeString(ref) if err == nil { return core.PtrTo(plumbing.NewHash(ref)), nil } refs, err := repository.References() if err != nil { return nil, fmt.Errorf("could not get references: %w", err) } remotes, err := repository.Remotes() if err != nil { return nil, fmt.Errorf("could not get remotes: %w", err) } err = refs.ForEach(func(reference *plumbing.Reference) error { if reference.Name().String() == ref { res = core.PtrTo(reference.Hash()) } for _, remote := range remotes { refName := plumbing.NewRemoteReferenceName(remote.Config().Name, ref) if refName == reference.Name() { res = core.PtrTo(reference.Hash()) } } return nil }) if err != nil { return nil, errors.New("could not iterate through references") } if res != nil { return res, nil } return nil, fmt.Errorf("could not convert reference %s to %T", ref, core.PtrTo(plumbing.NewHash(""))) } // fastDiff is a faster way to get the diff between two commits. // it returns a boolean rather than a full blob diff. func fastDiff(from, to *object.Commit) (changedFiles []string, err error) { fromTree, err := from.Tree() if err != nil { return []string{}, fmt.Errorf("could not get tree for commit %s: %w", from.Hash.String(), err) } toTree, err := to.Tree() if err != nil { return []string{}, fmt.Errorf("could not get tree for commit %s: %w", to.Hash.String(), err) } changes, err := fromTree.Diff(toTree) if err != nil { return []string{}, fmt.Errorf("could not get diff for commit %s: %w", to.Hash.String(), err) } for _, change := range changes { if change.From.Name != "" { changedFiles = append(changedFiles, change.From.Name) } if change.To.Name != "" { changedFiles = append(changedFiles, change.To.Name) } } return changedFiles, nil } func tryGetPushEvent() (base, head string, ok bool, err error) { f, err := os.Open(os.Getenv("GITHUB_EVENT_PATH")) if err != nil { return "", "", false, fmt.Errorf("could not open event path: %w", err) } defer func() { _ = f.Close() }() var gpe github.PushEvent if err := json.NewDecoder(f).Decode(&gpe); err != nil { return "gpe", "", false, fmt.Errorf("could not decode event: %w", err) } return gpe.GetBefore(), gpe.GetAfter(), true, nil } // note: we don't handle the case of no previous commit, this will error. func getHeadBase(repo *git.Repository) (head string, base string, err error) { co, err := repo.Head() if err != nil { return "", "", fmt.Errorf("could not get head: %w", err) } citer, err := repo.Log(&git.LogOptions{From: co.Hash()}) if err != nil { return "", "", fmt.Errorf("could not get logs: %w", err) } _, err = citer.Next() if err != nil { return "", "", fmt.Errorf("could not pass head: %w", err) } lastCommit, err := citer.Next() if err != nil { return "", "", fmt.Errorf("could not get last commit: %w", err) } return co.Hash().String(), lastCommit.Hash.String(), nil } // GetHead gets the head of the current branch. // it attempts to mirror the logic of https://github.com/dorny/paths-filter/blob/0ef5f0d812dc7b631d69e07d2491d70fcebc25c8/src/main.ts#L104 func GetHead(repo *git.Repository, ghContext *actionscore.Context, head string) (string, error) { if head != "" { return head, nil } if ghContext.Ref != "" { return ghContext.Ref, nil } gitHead, err := repo.Head() if err != nil { // TODO: there's some other logic here: https://github.com/dorny/paths-filter/blob/4067d885736b84de7c414f582ac45897079b0a78/src/git.ts#L174 // we might want to build in return "", fmt.Errorf("could not get head: %w", err) } return gitHead.Name().Short(), nil } func getBase(ghContext *actionscore.Context, base string) string { if base != "" { return base } return ghContext.Payload.Repository.GetDefaultBranch() } // emulates https://github.com/dorny/paths-filter/blob/master/src/git.ts#L185 func getShortName(ref string) string { const heads = "refs/heads/" const tags = "refs/tags/" if strings.HasPrefix(ref, heads) { return strings.TrimPrefix(ref, heads) } if strings.HasPrefix(ref, tags) { return strings.TrimPrefix(ref, tags) } return ref }