package dehub import ( "encoding/hex" "errors" "fmt" "path/filepath" "strings" "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/object" ) // Commit wraps a single git commit object, and also contains various fields // which are parsed out of it, including the payload. It is used as a // convenience type, in place of having to manually retrieve and parse specific // information out of commit objects. type Commit struct { Payload PayloadUnion Hash plumbing.Hash Object *object.Commit TreeObject *object.Tree } // GetCommit retrieves the Commit at the given hash, and all of its sub-data // which can be pulled out of it. func (proj *Project) GetCommit(h plumbing.Hash) (c Commit, err error) { if c.Object, err = proj.GitRepo.CommitObject(h); err != nil { return c, fmt.Errorf("getting git commit object: %w", err) } else if c.TreeObject, err = proj.GitRepo.TreeObject(c.Object.TreeHash); err != nil { return c, fmt.Errorf("getting git tree object %q: %w", c.Object.TreeHash, err) } else if err = c.Payload.UnmarshalText([]byte(c.Object.Message)); err != nil { return c, fmt.Errorf("decoding commit message: %w", err) } c.Hash = c.Object.Hash return } // ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An // example of when this can happen is if the project was just initialized and // has no commits, or if an orphan branch is checked out. var ErrHeadIsZero = errors.New("HEAD resolves to the zero hash") // GetHeadCommit returns the Commit which is currently referenced by HEAD. // This method may return ErrHeadIsZero if HEAD resolves to the zero hash. func (proj *Project) GetHeadCommit() (Commit, error) { headHash, err := proj.ReferenceToHash(plumbing.HEAD) if err != nil { return Commit{}, fmt.Errorf("resolving HEAD: %w", err) } else if headHash == plumbing.ZeroHash { return Commit{}, ErrHeadIsZero } c, err := proj.GetCommit(headHash) if err != nil { return Commit{}, fmt.Errorf("getting commit %q: %w", headHash, err) } return c, nil } // GetCommitRange returns an ancestry of Commits, with the first being the // commit immediately following the given starting hash, and the last being the // given ending hash. // // If start is plumbing.ZeroHash then the root commit will be the starting hash. func (proj *Project) GetCommitRange(start, end plumbing.Hash) ([]Commit, error) { curr, err := proj.GetCommit(end) if err != nil { return nil, fmt.Errorf("retrieving commit %q: %w", end, err) } var commits []Commit var found bool for { if found = start != plumbing.ZeroHash && curr.Hash == start; found { break } commits = append(commits, curr) numParents := curr.Object.NumParents() if numParents == 0 { break } else if numParents > 1 { return nil, fmt.Errorf("commit %q has more than one parent: %+v", curr.Hash, curr.Object.ParentHashes) } parentHash := curr.Object.ParentHashes[0] parent, err := proj.GetCommit(parentHash) if err != nil { return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err) } curr = parent } if !found && start != plumbing.ZeroHash { return nil, fmt.Errorf("unable to find commit %q as an ancestor of %q", start, end) } // reverse the commits to be in the expected order for l, r := 0, len(commits)-1; l < r; l, r = l+1, r-1 { commits[l], commits[r] = commits[r], commits[l] } return commits, nil } var ( hashStrLen = len(plumbing.ZeroHash.String()) errNotHex = errors.New("not a valid hex string") ) func (proj *Project) findCommitByShortHash(hashStr string) (plumbing.Hash, error) { paddedHashStr := hashStr if len(hashStr)%2 > 0 { paddedHashStr += "0" } if hashB, err := hex.DecodeString(paddedHashStr); err != nil { return plumbing.ZeroHash, errNotHex } else if len(hashStr) == hashStrLen { var hash plumbing.Hash copy(hash[:], hashB) return hash, nil } else if len(hashStr) < 2 { return plumbing.ZeroHash, errors.New("hash string must be 2 characters long or more") } for i := 2; i < hashStrLen; i++ { hashPrefix, hashTail := hashStr[:i], hashStr[i:] path := filepath.Join("objects", hashPrefix) fileInfos, err := proj.GitDirFS.ReadDir(path) if err != nil { return plumbing.ZeroHash, fmt.Errorf("listing files in %q: %w", path, err) } var matchedHash plumbing.Hash for _, fileInfo := range fileInfos { objFileName := fileInfo.Name() if !strings.HasPrefix(objFileName, hashTail) { continue } objHash := plumbing.NewHash(hashPrefix + objFileName) obj, err := proj.GitRepo.Storer.EncodedObject(plumbing.AnyObject, objHash) if err != nil { return plumbing.ZeroHash, fmt.Errorf("reading object %q off disk: %w", objHash, err) } else if obj.Type() != plumbing.CommitObject { continue } else if matchedHash == plumbing.ZeroHash { matchedHash = objHash continue } return plumbing.ZeroHash, fmt.Errorf("both %q and %q match", matchedHash, objHash) } if matchedHash != plumbing.ZeroHash { return matchedHash, nil } } return plumbing.ZeroHash, errors.New("failed to find a commit object with a matching prefix") } func (proj *Project) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) { if rev == plumbing.Revision(plumbing.ZeroHash.String()) { return plumbing.ZeroHash, nil } { // pretend the revision is a short hash until proven otherwise shortHash := string(rev) hash, err := proj.findCommitByShortHash(shortHash) if errors.Is(err, errNotHex) { // ok, continue } else if err != nil { return plumbing.ZeroHash, fmt.Errorf("resolving as short hash: %w", err) } else { // guess it _is_ a short hash, knew it! return hash, nil } } h, err := proj.GitRepo.ResolveRevision(rev) if err != nil { return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err) } return *h, nil } // GetCommitByRevision resolves the revision and returns the Commit it references. func (proj *Project) GetCommitByRevision(rev plumbing.Revision) (Commit, error) { hash, err := proj.resolveRev(rev) if err != nil { return Commit{}, err } c, err := proj.GetCommit(hash) if err != nil { return Commit{}, fmt.Errorf("getting commit %q: %w", hash, err) } return c, nil } // GetCommitRangeByRevision is like GetCommitRange, first resolving the given // revisions into hashes before continuing with GetCommitRange's behavior. func (proj *Project) GetCommitRangeByRevision(startRev, endRev plumbing.Revision) ([]Commit, error) { start, err := proj.resolveRev(startRev) if err != nil { return nil, err } end, err := proj.resolveRev(endRev) if err != nil { return nil, err } return proj.GetCommitRange(start, end) }