package git
import (
"bytes"
"context"
"errors"
"fmt"
"net/url"
"path"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/format/index"
)
var (
ErrSubmoduleAlreadyInitialized = errors.New("submodule already initialized")
ErrSubmoduleNotInitialized = errors.New("submodule not initialized")
)
// Submodule a submodule allows you to keep another Git repository in a
// subdirectory of your repository.
type Submodule struct {
// initialized defines if a submodule was already initialized.
initialized bool
c *config.Submodule
w *Worktree
}
// Config returns the submodule config
func (s *Submodule) Config() *config.Submodule {
return s.c
}
// Init initialize the submodule reading the recorded Entry in the index for
// the given submodule
func (s *Submodule) Init() error {
cfg, err := s.w.r.Config()
if err != nil {
return err
}
_, ok := cfg.Submodules[s.c.Name]
if ok {
return ErrSubmoduleAlreadyInitialized
}
s.initialized = true
cfg.Submodules[s.c.Name] = s.c
return s.w.r.Storer.SetConfig(cfg)
}
// Status returns the status of the submodule.
func (s *Submodule) Status() (*SubmoduleStatus, error) {
idx, err := s.w.r.Storer.Index()
if err != nil {
return nil, err
}
return s.status(idx)
}
func (s *Submodule) status(idx *index.Index) (*SubmoduleStatus, error) {
status := &SubmoduleStatus{
Path: s.c.Path,
}
e, err := idx.Entry(s.c.Path)
if err != nil && err != index.ErrEntryNotFound {
return nil, err
}
if e != nil {
status.Expected = e.Hash
}
if !s.initialized {
return status, nil
}
r, err := s.Repository()
if err != nil {
return nil, err
}
head, err := r.Head()
if err == nil {
status.Current = head.Hash()
}
if err != nil && err == plumbing.ErrReferenceNotFound {
err = nil
}
return status, err
}
// Repository returns the Repository represented by this submodule
func (s *Submodule) Repository() (*Repository, error) {
if !s.initialized {
return nil, ErrSubmoduleNotInitialized
}
storer, err := s.w.r.Storer.Module(s.c.Name)
if err != nil {
return nil, err
}
_, err = storer.Reference(plumbing.HEAD)
if err != nil && err != plumbing.ErrReferenceNotFound {
return nil, err
}
var exists bool
if err == nil {
exists = true
}
var worktree billy.Filesystem
if worktree, err = s.w.Filesystem.Chroot(s.c.Path); err != nil {
return nil, err
}
if exists {
return Open(storer, worktree)
}
r, err := Init(storer, worktree)
if err != nil {
return nil, err
}
moduleURL, err := url.Parse(s.c.URL)
if err != nil {
return nil, err
}
if !path.IsAbs(moduleURL.Path) {
remotes, err := s.w.r.Remotes()
if err != nil {
return nil, err
}
rootURL, err := url.Parse(remotes[0].c.URLs[0])
if err != nil {
return nil, err
}
rootURL.Path = path.Join(rootURL.Path, moduleURL.Path)
*moduleURL = *rootURL
}
_, err = r.CreateRemote(&config.RemoteConfig{
Name: DefaultRemoteName,
URLs: []string{moduleURL.String()},
})
return r, err
}
// Update the registered submodule to match what the superproject expects, the
// submodule should be initialized first calling the Init method or setting in
// the options SubmoduleUpdateOptions.Init equals true
func (s *Submodule) Update(o *SubmoduleUpdateOptions) error {
return s.UpdateContext(context.Background(), o)
}
// UpdateContext the registered submodule to match what the superproject
// expects, the submodule should be initialized first calling the Init method or
// setting in the options SubmoduleUpdateOptions.Init equals true.
//
// The provided Context must be non-nil. If the context expires before the
// operation is complete, an error is returned. The context only affects the
// transport operations.
func (s *Submodule) UpdateContext(ctx context.Context, o *SubmoduleUpdateOptions) error {
return s.update(ctx, o, plumbing.ZeroHash)
}
func (s *Submodule) update(ctx context.Context, o *SubmoduleUpdateOptions, forceHash plumbing.Hash) error {
if !s.initialized && !o.Init {
return ErrSubmoduleNotInitialized
}
if !s.initialized && o.Init {
if err := s.Init(); err != nil {
return err
}
}
idx, err := s.w.r.Storer.Index()
if err != nil {
return err
}
hash := forceHash
if hash.IsZero() {
e, err := idx.Entry(s.c.Path)
if err != nil {
return err
}
hash = e.Hash
}
r, err := s.Repository()
if err != nil {
return err
}
if err := s.fetchAndCheckout(ctx, r, o, hash); err != nil {
return err
}
return s.doRecursiveUpdate(r, o)
}
func (s *Submodule) doRecursiveUpdate(r *Repository, o *SubmoduleUpdateOptions) error {
if o.RecurseSubmodules == NoRecurseSubmodules {
return nil
}
w, err := r.Worktree()
if err != nil {
return err
}
l, err := w.Submodules()
if err != nil {
return err
}
new := &SubmoduleUpdateOptions{}
*new = *o
new.RecurseSubmodules--
return l.Update(new)
}
func (s *Submodule) fetchAndCheckout(
ctx context.Context, r *Repository, o *SubmoduleUpdateOptions, hash plumbing.Hash,
) error {
if !o.NoFetch {
err := r.FetchContext(ctx, &FetchOptions{Auth: o.Auth})
if err != nil && err != NoErrAlreadyUpToDate {
return err
}
}
w, err := r.Worktree()
if err != nil {
return err
}
// Handle a case when submodule refers to an orphaned commit that's still reachable
// through Git server using a special protocol capability[1].
//
// [1]: https://git-scm.com/docs/protocol-capabilities#_allow_reachable_sha1_in_want
if !o.NoFetch {
if _, err := w.r.Object(plumbing.AnyObject, hash); err != nil {
refSpec := config.RefSpec("+" + hash.String() + ":" + hash.String())
err := r.FetchContext(ctx, &FetchOptions{
Auth: o.Auth,
RefSpecs: []config.RefSpec{refSpec},
})
if err != nil && err != NoErrAlreadyUpToDate && err != ErrExactSHA1NotSupported {
return err
}
}
}
if err := w.Checkout(&CheckoutOptions{Hash: hash}); err != nil {
return err
}
head := plumbing.NewHashReference(plumbing.HEAD, hash)
return r.Storer.SetReference(head)
}
// Submodules list of several submodules from the same repository.
type Submodules []*Submodule
// Init initializes the submodules in this list.
func (s Submodules) Init() error {
for _, sub := range s {
if err := sub.Init(); err != nil {
return err
}
}
return nil
}
// Update updates all the submodules in this list.
func (s Submodules) Update(o *SubmoduleUpdateOptions) error {
return s.UpdateContext(context.Background(), o)
}
// UpdateContext updates all the submodules in this list.
//
// The provided Context must be non-nil. If the context expires before the
// operation is complete, an error is returned. The context only affects the
// transport operations.
func (s Submodules) UpdateContext(ctx context.Context, o *SubmoduleUpdateOptions) error {
for _, sub := range s {
if err := sub.UpdateContext(ctx, o); err != nil {
return err
}
}
return nil
}
// Status returns the status of the submodules.
func (s Submodules) Status() (SubmodulesStatus, error) {
var list SubmodulesStatus
var r *Repository
for _, sub := range s {
if r == nil {
r = sub.w.r
}
idx, err := r.Storer.Index()
if err != nil {
return nil, err
}
status, err := sub.status(idx)
if err != nil {
return nil, err
}
list = append(list, status)
}
return list, nil
}
// SubmodulesStatus contains the status for all submodiles in the worktree
type SubmodulesStatus []*SubmoduleStatus
// String is equivalent to `git submodule status`
func (s SubmodulesStatus) String() string {
buf := bytes.NewBuffer(nil)
for _, sub := range s {
fmt.Fprintln(buf, sub)
}
return buf.String()
}
// SubmoduleStatus contains the status for a submodule in the worktree
type SubmoduleStatus struct {
Path string
Current plumbing.Hash
Expected plumbing.Hash
Branch plumbing.ReferenceName
}
// IsClean is the HEAD of the submodule is equals to the expected commit
func (s *SubmoduleStatus) IsClean() bool {
return s.Current == s.Expected
}
// String is equivalent to `git submodule status <submodule>`
//
// This will print the SHA-1 of the currently checked out commit for a
// submodule, along with the submodule path and the output of git describe fo
// the SHA-1. Each SHA-1 will be prefixed with - if the submodule is not
// initialized, + if the currently checked out submodule commit does not match
// the SHA-1 found in the index of the containing repository.
func (s *SubmoduleStatus) String() string {
var extra string
var status = ' '
if s.Current.IsZero() {
status = '-'
} else if !s.IsClean() {
status = '+'
}
if len(s.Branch) != 0 {
extra = string(s.Branch[5:])
} else if !s.Current.IsZero() {
extra = s.Current.String()[:7]
}
if extra != "" {
extra = fmt.Sprintf(" (%s)", extra)
}
return fmt.Sprintf("%c%s %s%s", status, s.Expected, s.Path, extra)
}