// Package dehub TODO needs package docs package dehub import ( "bytes" "errors" "fmt" "io" "os" "path/filepath" "dehub.dev/src/dehub.git/fs" "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/cache" "gopkg.in/src-d/go-git.v4/plumbing/format/config" "gopkg.in/src-d/go-git.v4/storage" "gopkg.in/src-d/go-git.v4/storage/filesystem" ) const ( // DehubDir defines the name of the directory where all dehub-related files // are expected to be found within the git repo. DehubDir = ".dehub" ) var ( // ConfigPath defines the expected path to the Project'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 openOpts struct { bare bool } // OpenOption is an option which can be passed to the OpenProject function to // affect the Project's behavior. type OpenOption func(*openOpts) // OpenBareRepo returns an OpenOption which, if true is given, causes the // OpenProject function to expect to open a bare git repo. func OpenBareRepo(bare bool) OpenOption { return func(o *openOpts) { o.bare = bare } } // Project implements accessing and modifying a local dehub project, as well as // extending the functionality of the underlying git repo in ways which are // specifically useful for dehub projects. type Project struct { // GitRepo is the git repository which houses the project. GitRepo *git.Repository // GitDirFS corresponds to the .git directory (or the entire repo directory // if it's a bare repo) GitDirFS billy.Filesystem } func extractGitDirFS(storer storage.Storer) (billy.Filesystem, error) { dotGitFSer, ok := storer.(interface{ Filesystem() billy.Filesystem }) if !ok { return nil, fmt.Errorf("git storage object of type %T does not expose its underlying filesystem", storer) } return dotGitFSer.Filesystem(), nil } // OpenProject opens the dehub project in the given directory and returns a // Project instance for it. // // The given path is expected to have a git repo already initialized. func OpenProject(path string, options ...OpenOption) (*Project, error) { var opts openOpts for _, opt := range options { opt(&opts) } proj := Project{} var err error openOpts := &git.PlainOpenOptions{ DetectDotGit: !opts.bare, } if proj.GitRepo, err = git.PlainOpenWithOptions(path, openOpts); err != nil { return nil, fmt.Errorf("opening git repo: %w", err) } else if proj.GitDirFS, err = extractGitDirFS(proj.GitRepo.Storer); err != nil { return nil, err } return &proj, nil } type initOpts struct { bare bool remote bool } // InitOption is an option which can be passed into the Init functions to affect // their behavior. type InitOption func(*initOpts) // InitBareRepo returns an InitOption which, if true is given, causes the Init // function to initialize the project's git repo without a worktree. func InitBareRepo(bare bool) InitOption { return func(o *initOpts) { o.bare = bare } } // InitRemoteRepo returns an InitOption which, if true is given, causes the Init // function to initialize the project's git repo with certain git configuration // options set which make the repo able to be used as a remote repo. func InitRemoteRepo(remote bool) InitOption { return func(o *initOpts) { o.remote = remote } } // InitProject will initialize a new project at the given path. If bare is true // then the project's git repo will not have a worktree. func InitProject(path string, options ...InitOption) (*Project, error) { var opts initOpts for _, opt := range options { opt(&opts) } var proj Project var err error if proj.GitRepo, err = git.PlainInit(path, opts.bare); err != nil { return nil, fmt.Errorf("initializing git repo: %w", err) } else if proj.GitDirFS, err = extractGitDirFS(proj.GitRepo.Storer); err != nil { return nil, err } else if err = proj.init(opts); err != nil { return nil, fmt.Errorf("initializing repo with dehub defaults: %w", err) } return &proj, nil } // InitMemProject initializes an empty project which only exists in memory. func InitMemProject(options ...InitOption) *Project { var opts initOpts for _, opt := range options { opt(&opts) } fs := memfs.New() dotGitFS, err := fs.Chroot(git.GitDirName) if err != nil { panic(err) } storage := filesystem.NewStorage(dotGitFS, cache.NewObjectLRUDefault()) var worktree billy.Filesystem if !opts.bare { worktree = fs } r, err := git.Init(storage, worktree) if err != nil { panic(err) } proj := &Project{GitRepo: r, GitDirFS: dotGitFS} if err := proj.init(opts); err != nil { panic(err) } return proj } func (proj *Project) initRemotePreReceive(bare bool) error { if err := proj.GitDirFS.MkdirAll("hooks", 0755); err != nil { return fmt.Errorf("creating hooks directory: %w", err) } preRcvFlags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC preRcv, err := proj.GitDirFS.OpenFile("hooks/pre-receive", preRcvFlags, 0755) if err != nil { return fmt.Errorf("opening hooks/pre-receive file: %w", err) } defer preRcv.Close() var preRcvBody string if bare { preRcvBody = "#!/bin/sh\nexec dehub hook -bare pre-receive\n" } else { preRcvBody = "#!/bin/sh\nexec dehub hook pre-receive\n" } if _, err := io.Copy(preRcv, bytes.NewBufferString(preRcvBody)); err != nil { return fmt.Errorf("writing to hooks/pre-receive: %w", err) } return nil } func (proj *Project) init(opts initOpts) error { headRef := plumbing.NewSymbolicReference(plumbing.HEAD, MainRefName) if err := proj.GitRepo.Storer.SetReference(headRef); err != nil { return fmt.Errorf("setting HEAD reference to %q: %w", MainRefName, err) } if opts.remote { cfg, err := proj.GitRepo.Config() if err != nil { return fmt.Errorf("opening git cfg: %w", err) } cfg.Raw = cfg.Raw.AddOption("http", config.NoSubsection, "receivepack", "true") if err := proj.GitRepo.Storer.SetConfig(cfg); err != nil { return fmt.Errorf("storing modified git config: %w", err) } if err := proj.initRemotePreReceive(opts.bare); err != nil { return fmt.Errorf("initializing pre-receive hook for remote-enabled repo: %w", err) } } return nil } func (proj *Project) billyFilesystem() (billy.Filesystem, error) { w, err := proj.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 (proj *Project) TraverseReferenceChain(refName plumbing.ReferenceName, pred func(*plumbing.Reference) bool) (*plumbing.Reference, error) { // TODO infinite loop checking // TODO check that this (and the methods which use it) are actually useful for { ref, err := proj.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() } } // ErrNoBranchReference is returned from ReferenceToBranchName if no reference // in the reference chain is for a branch. var ErrNoBranchReference = errors.New("no branch reference found") // ReferenceToBranchName traverses a chain of references looking for the first // branch reference, and returns that name, or returns ErrNoBranchReference if // no branch reference is part of the chain. func (proj *Project) 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 := proj.TraverseReferenceChain(refName, func(ref *plumbing.Reference) bool { return ref.Target().IsBranch() }) if errors.Is(err, errTraverseRefNoMatch) { return "", ErrNoBranchReference } 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 (proj *Project) ReferenceToHash(refName plumbing.ReferenceName) (plumbing.Hash, error) { ref, err := proj.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 (proj *Project) headFS() (fs.FS, error) { head, err := proj.GetHeadCommit() if errors.Is(err, ErrHeadIsZero) { bfs, err := proj.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.TreeObject), nil }