// Package dehub TODO needs package docs package dehub import ( "dehub.dev/src/dehub.git/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) }