8cbdc03caa
--- type: change message: |- Implement commit combining Adds a method CombineCommitChanges which takes in a range of commits and a branch. It determines what the change_hash of the range is (using the message of the last change commit in the range, and the file diff across the full range), collects all credentials for that hash, and creates a new change commit with all that information. That new change commit is then added onto the given branch (but only if the change_hash would remain the same by doing so). A corresponding combine command has also been added to the dehub binary. change_hash: ALpURxN61PHUo5uzN9GTlM5V+4YcirKWpZr4G9llZVdu credentials: - type: pgp_signature pub_key_id: 95C46FA6A41148AC body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl53+xAACgkQlcRvpqQRSKxbZBAAsnLhOuVQ1YM2xVWVJTJSY09PoyD1EyXMFP/H5gCMvu9wtYHwPO2BOGsmWsmIePzRLH0YHAp3VLBtBiyPy5Xm5e9L54DJc1IvOH7tfP5jyDodExHhhCVvNeGza4ngcyBBi+ywu3SwNq1eYms/A4o8ZxcRRPhEYfGHR2Tob31ntbTM9s1D8cqZzszuj/FJY9AAEWmlBVfJrdPUGZ/DqYplTypEvgfSmbTsKWG6sc3nQu66azDvTz3v6Jn+oLQNdeFAA0gaXvChCWaAtsnDglSgzMTigz4+jY+ruqmvM+J4g7SYiWHrSzABM2QnBBvhMuNcFQJ/o7vQ3sWBlHDEJf+OLlW6APRbPfY/xZn7+TCQSi3UyVuSctqSeQfnCI3pDm2C17mtBeuYLtXDrDsENPOm9SEBz/rCpFVNyVI3TS5o+2EAolFQmEf7tV9ai32v5qqqdxwIS66EwW+eNVwpEpwdJrRRddlQ8Nn49Hgk7NHmniSxKn92BRVjtg/l92tvrNgfopQHRM9rnDcKtd0qvgVbmVOdgx55NnEMMYbOV0ljesmos++x3WpeH35WpOBV60rIbRCVPaSn1SqaEjeLXHKkc35EYgiUoD+F3pP7vfpbUJ5Wq8fIirFXnm6nA2WlBW8GXFEfd5tnjTkPAd9MSyqZz6bu0bDS3l4x7+qPgUlrQDg= account: mediocregopher
336 lines
10 KiB
Go
336 lines
10 KiB
Go
// Package dehub TODO needs package docs
|
|
package dehub
|
|
|
|
import (
|
|
"dehub/fs"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"gopkg.in/src-d/go-billy.v4"
|
|
"gopkg.in/src-d/go-billy.v4/memfs"
|
|
"gopkg.in/src-d/go-git.v4"
|
|
"gopkg.in/src-d/go-git.v4/plumbing"
|
|
"gopkg.in/src-d/go-git.v4/plumbing/object"
|
|
"gopkg.in/src-d/go-git.v4/storage"
|
|
"gopkg.in/src-d/go-git.v4/storage/memory"
|
|
)
|
|
|
|
const (
|
|
// DehubDir defines the name of the directory where all dehub-related files are
|
|
// expected to be found.
|
|
DehubDir = ".dehub"
|
|
)
|
|
|
|
var (
|
|
// ConfigPath defines the expected path to the Repo's configuration file.
|
|
ConfigPath = filepath.Join(DehubDir, "config.yml")
|
|
|
|
// Main defines the name of the main branch.
|
|
Main = "main"
|
|
|
|
// MainRefName defines the reference name of the main branch.
|
|
MainRefName = plumbing.NewBranchReferenceName(Main)
|
|
)
|
|
|
|
type repoOpts struct {
|
|
bare bool
|
|
}
|
|
|
|
// OpenOption is an option which can be passed to the OpenRepo function to
|
|
// affect the Repo's behavior.
|
|
type OpenOption func(*repoOpts)
|
|
|
|
// OpenBare returns an OpenOption which, if true is given, causes the OpenRepo
|
|
// function to expect to open a bare repo.
|
|
func OpenBare(bare bool) OpenOption {
|
|
return func(o *repoOpts) {
|
|
o.bare = bare
|
|
}
|
|
}
|
|
|
|
// Repo is an object which allows accessing and modifying the dehub repo.
|
|
type Repo struct {
|
|
GitRepo *git.Repository
|
|
Storer storage.Storer
|
|
}
|
|
|
|
// OpenRepo opens the dehub repo in the given directory and returns the object
|
|
// for it.
|
|
//
|
|
// The given path is expected to have a git repo and .dehub folder already
|
|
// initialized.
|
|
func OpenRepo(path string, options ...OpenOption) (*Repo, error) {
|
|
var opts repoOpts
|
|
for _, opt := range options {
|
|
opt(&opts)
|
|
}
|
|
|
|
r := Repo{}
|
|
var err error
|
|
openOpts := &git.PlainOpenOptions{
|
|
DetectDotGit: !opts.bare,
|
|
}
|
|
if r.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil {
|
|
return nil, fmt.Errorf("could not open git repo: %w", err)
|
|
}
|
|
|
|
return &r, nil
|
|
}
|
|
|
|
// InitMemRepo initializes an empty repository which only exists in memory.
|
|
func InitMemRepo() *Repo {
|
|
r, err := git.Init(memory.NewStorage(), memfs.New())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
repo := &Repo{GitRepo: r}
|
|
if err := repo.init(); err != nil {
|
|
panic(err)
|
|
}
|
|
return repo
|
|
}
|
|
|
|
func (r *Repo) init() error {
|
|
headRef := plumbing.NewSymbolicReference(plumbing.HEAD, MainRefName)
|
|
if err := r.GitRepo.Storer.SetReference(headRef); err != nil {
|
|
return fmt.Errorf("setting HEAD reference to %q: %w", MainRefName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repo) billyFilesystem() (billy.Filesystem, error) {
|
|
w, err := r.GitRepo.Worktree()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening git worktree: %w", err)
|
|
}
|
|
return w.Filesystem, nil
|
|
}
|
|
|
|
var errTraverseRefNoMatch = errors.New("failed to find reference matching given predicate")
|
|
|
|
// TraverseReferenceChain resolves a chain of references, calling the given
|
|
// predicate on each one, and returning the first one for which the predicate
|
|
// returns true. This method will return an error if it reaches the end of the
|
|
// chain and the predicate still has not returned true.
|
|
//
|
|
// If a reference name is encountered which does not actually exist, then it is
|
|
// assumed to be a hash reference to the zero hash.
|
|
func (r *Repo) TraverseReferenceChain(refName plumbing.ReferenceName, pred func(*plumbing.Reference) bool) (*plumbing.Reference, error) {
|
|
// TODO infinite loop checking
|
|
for {
|
|
ref, err := r.GitRepo.Storer.Reference(refName)
|
|
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
|
ref = plumbing.NewHashReference(refName, plumbing.ZeroHash)
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("resolving reference %q: %w", refName, err)
|
|
}
|
|
|
|
if pred(ref) {
|
|
return ref, nil
|
|
} else if ref.Type() != plumbing.SymbolicReference {
|
|
return nil, errTraverseRefNoMatch
|
|
}
|
|
refName = ref.Target()
|
|
}
|
|
}
|
|
|
|
// ReferenceToBranchName traverses a chain of references looking for the first
|
|
// branch reference, and returns that name, or returns an error if no branch
|
|
// reference is part of the chain.
|
|
func (r *Repo) ReferenceToBranchName(refName plumbing.ReferenceName) (plumbing.ReferenceName, error) {
|
|
// first check if the given refName is a branch, if so just return that.
|
|
if refName.IsBranch() {
|
|
return refName, nil
|
|
}
|
|
|
|
ref, err := r.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool {
|
|
return ref.Target().IsBranch()
|
|
})
|
|
if errors.Is(err, errTraverseRefNoMatch) {
|
|
return "", errors.New("no branch in reference chain")
|
|
} else if err != nil {
|
|
return "", fmt.Errorf("traversing reference chain: %w", err)
|
|
}
|
|
return ref.Target(), nil
|
|
}
|
|
|
|
// ReferenceToHash fully resolves a reference to a hash. If a reference cannot
|
|
// be resolved then plumbing.ZeroHash is returned.
|
|
func (r *Repo) ReferenceToHash(refName plumbing.ReferenceName) (plumbing.Hash, error) {
|
|
ref, err := r.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool {
|
|
return ref.Type() == plumbing.HashReference
|
|
})
|
|
if errors.Is(err, errTraverseRefNoMatch) {
|
|
return plumbing.ZeroHash, errors.New("no hash in reference chain (is this even possible???)")
|
|
} else if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
|
return plumbing.ZeroHash, nil
|
|
} else if err != nil {
|
|
return plumbing.ZeroHash, fmt.Errorf("traversing reference chain: %w", err)
|
|
}
|
|
return ref.Hash(), nil
|
|
}
|
|
|
|
// headFS returns an FS based on the HEAD commit, or if there is no HEAD commit
|
|
// (it's an empty repo) an FS based on the raw filesystem.
|
|
func (r *Repo) headFS() (fs.FS, error) {
|
|
head, err := r.GetGitHead()
|
|
if errors.Is(err, ErrHeadIsZero) {
|
|
bfs, err := r.billyFilesystem()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting underlying filesystem: %w", err)
|
|
}
|
|
return fs.FromBillyFilesystem(bfs), nil
|
|
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("could not get HEAD tree: %w", err)
|
|
}
|
|
|
|
return fs.FromTree(head.GitTree), nil
|
|
}
|
|
|
|
// GitCommit wraps a single git commit object, and also contains various fields
|
|
// which are parsed out of it. It is used as a convenience type, in place of
|
|
// having to manually retrieve and parse specific information out of commit
|
|
// objects.
|
|
type GitCommit struct {
|
|
GitCommit *object.Commit
|
|
|
|
// Fields based on that Commit, which can't be directly gleaned from it.
|
|
GitTree *object.Tree
|
|
Commit Commit
|
|
Interface CommitInterface
|
|
}
|
|
|
|
// Root returns true if this commit is the root commit in its branch (i.e. it
|
|
// has no parents)
|
|
func (gc GitCommit) Root() bool {
|
|
return gc.GitCommit.NumParents() == 0
|
|
}
|
|
|
|
// GetGitCommit retrieves the commit at the given hash, and all of its sub-data
|
|
// which can be pulled out of it.
|
|
func (r *Repo) GetGitCommit(h plumbing.Hash) (gc GitCommit, err error) {
|
|
if gc.GitCommit, err = r.GitRepo.CommitObject(h); err != nil {
|
|
return gc, fmt.Errorf("getting git commit object: %w", err)
|
|
} else if gc.GitTree, err = r.GitRepo.TreeObject(gc.GitCommit.TreeHash); err != nil {
|
|
return gc, fmt.Errorf("getting git tree object %q: %w",
|
|
gc.GitCommit.TreeHash, err)
|
|
} else if gc.Commit.UnmarshalText([]byte(gc.GitCommit.Message)); err != nil {
|
|
return gc, fmt.Errorf("decoding commit message: %w", err)
|
|
} else if gc.Interface, err = gc.Commit.Interface(); err != nil {
|
|
return gc, fmt.Errorf("casting %+v to a CommitInterface: %w", gc.Commit, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// GetGitRevision resolves the revision and returns the GitCommit it references.
|
|
func (r *Repo) GetGitRevision(rev plumbing.Revision) (GitCommit, error) {
|
|
// This returns a pointer for some reason, not sure why.
|
|
h, err := r.GitRepo.ResolveRevision(rev)
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("resolving revision: %w", err)
|
|
}
|
|
|
|
gc, err := r.GetGitCommit(*h)
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("getting commit %q: %w", *h, err)
|
|
}
|
|
return gc, nil
|
|
}
|
|
|
|
// ErrHeadIsZero is used to indicate that HEAD resolves to the zero hash. An
|
|
// example of when this can happen is if the repo 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")
|
|
|
|
// GetGitHead returns the GitCommit which is currently referenced by HEAD.
|
|
// This method may return ErrHeadIsZero if HEAD resolves to the zero hash.
|
|
func (r *Repo) GetGitHead() (GitCommit, error) {
|
|
headHash, err := r.ReferenceToHash(plumbing.HEAD)
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("resolving HEAD: %w", err)
|
|
} else if headHash == plumbing.ZeroHash {
|
|
return GitCommit{}, ErrHeadIsZero
|
|
}
|
|
|
|
gc, err := r.GetGitCommit(headHash)
|
|
if err != nil {
|
|
return GitCommit{}, fmt.Errorf("getting commit %q: %w", headHash, err)
|
|
}
|
|
return gc, nil
|
|
}
|
|
|
|
// GetGitCommitRange returns an ancestry of GitCommits, 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 one.
|
|
func (r *Repo) GetGitCommitRange(start, end plumbing.Hash) ([]GitCommit, error) {
|
|
curr, err := r.GetGitCommit(end)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("retrieving commit %q: %w", end, err)
|
|
}
|
|
|
|
var commits []GitCommit
|
|
var found bool
|
|
for {
|
|
commits = append(commits, curr)
|
|
numParents := curr.GitCommit.NumParents()
|
|
if numParents == 0 {
|
|
break
|
|
} else if numParents > 1 {
|
|
return nil, fmt.Errorf("commit %q has more than one parent: %+v",
|
|
curr.GitCommit.Hash, curr.GitCommit.ParentHashes)
|
|
}
|
|
|
|
parentHash := curr.GitCommit.ParentHashes[0]
|
|
parent, err := r.GetGitCommit(parentHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("retrieving commit %q: %w", parentHash, err)
|
|
} else if start != plumbing.ZeroHash && parentHash == start {
|
|
found = true
|
|
break
|
|
}
|
|
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
|
|
}
|
|
|
|
func (r *Repo) resolveRev(rev plumbing.Revision) (plumbing.Hash, error) {
|
|
if rev == plumbing.Revision(plumbing.ZeroHash.String()) {
|
|
return plumbing.ZeroHash, nil
|
|
}
|
|
h, err := r.GitRepo.ResolveRevision(rev)
|
|
if err != nil {
|
|
return plumbing.ZeroHash, fmt.Errorf("resolving revision %q: %w", rev, err)
|
|
}
|
|
return *h, nil
|
|
}
|
|
|
|
// GetGitRevisionRange is like GetGitCommitRange, first resolving the given
|
|
// revisions into hashes before continuing with GetGitCommitRange's behavior.
|
|
func (r *Repo) GetGitRevisionRange(startRev, endRev plumbing.Revision) ([]GitCommit, error) {
|
|
start, err := r.resolveRev(startRev)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
end, err := r.resolveRev(endRev)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return r.GetGitCommitRange(start, end)
|
|
}
|