1package git
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"fmt"
8	"net/url"
9	"path"
10
11	"github.com/go-git/go-billy/v5"
12	"github.com/go-git/go-git/v5/config"
13	"github.com/go-git/go-git/v5/plumbing"
14	"github.com/go-git/go-git/v5/plumbing/format/index"
15)
16
17var (
18	ErrSubmoduleAlreadyInitialized = errors.New("submodule already initialized")
19	ErrSubmoduleNotInitialized     = errors.New("submodule not initialized")
20)
21
22// Submodule a submodule allows you to keep another Git repository in a
23// subdirectory of your repository.
24type Submodule struct {
25	// initialized defines if a submodule was already initialized.
26	initialized bool
27
28	c *config.Submodule
29	w *Worktree
30}
31
32// Config returns the submodule config
33func (s *Submodule) Config() *config.Submodule {
34	return s.c
35}
36
37// Init initialize the submodule reading the recorded Entry in the index for
38// the given submodule
39func (s *Submodule) Init() error {
40	cfg, err := s.w.r.Config()
41	if err != nil {
42		return err
43	}
44
45	_, ok := cfg.Submodules[s.c.Name]
46	if ok {
47		return ErrSubmoduleAlreadyInitialized
48	}
49
50	s.initialized = true
51
52	cfg.Submodules[s.c.Name] = s.c
53	return s.w.r.Storer.SetConfig(cfg)
54}
55
56// Status returns the status of the submodule.
57func (s *Submodule) Status() (*SubmoduleStatus, error) {
58	idx, err := s.w.r.Storer.Index()
59	if err != nil {
60		return nil, err
61	}
62
63	return s.status(idx)
64}
65
66func (s *Submodule) status(idx *index.Index) (*SubmoduleStatus, error) {
67	status := &SubmoduleStatus{
68		Path: s.c.Path,
69	}
70
71	e, err := idx.Entry(s.c.Path)
72	if err != nil && err != index.ErrEntryNotFound {
73		return nil, err
74	}
75
76	if e != nil {
77		status.Expected = e.Hash
78	}
79
80	if !s.initialized {
81		return status, nil
82	}
83
84	r, err := s.Repository()
85	if err != nil {
86		return nil, err
87	}
88
89	head, err := r.Head()
90	if err == nil {
91		status.Current = head.Hash()
92	}
93
94	if err != nil && err == plumbing.ErrReferenceNotFound {
95		err = nil
96	}
97
98	return status, err
99}
100
101// Repository returns the Repository represented by this submodule
102func (s *Submodule) Repository() (*Repository, error) {
103	if !s.initialized {
104		return nil, ErrSubmoduleNotInitialized
105	}
106
107	storer, err := s.w.r.Storer.Module(s.c.Name)
108	if err != nil {
109		return nil, err
110	}
111
112	_, err = storer.Reference(plumbing.HEAD)
113	if err != nil && err != plumbing.ErrReferenceNotFound {
114		return nil, err
115	}
116
117	var exists bool
118	if err == nil {
119		exists = true
120	}
121
122	var worktree billy.Filesystem
123	if worktree, err = s.w.Filesystem.Chroot(s.c.Path); err != nil {
124		return nil, err
125	}
126
127	if exists {
128		return Open(storer, worktree)
129	}
130
131	r, err := Init(storer, worktree)
132	if err != nil {
133		return nil, err
134	}
135
136	moduleURL, err := url.Parse(s.c.URL)
137	if err != nil {
138		return nil, err
139	}
140
141	if !path.IsAbs(moduleURL.Path) {
142		remotes, err := s.w.r.Remotes()
143		if err != nil {
144			return nil, err
145		}
146
147		rootURL, err := url.Parse(remotes[0].c.URLs[0])
148		if err != nil {
149			return nil, err
150		}
151
152		rootURL.Path = path.Join(rootURL.Path, moduleURL.Path)
153		*moduleURL = *rootURL
154	}
155
156	_, err = r.CreateRemote(&config.RemoteConfig{
157		Name: DefaultRemoteName,
158		URLs: []string{moduleURL.String()},
159	})
160
161	return r, err
162}
163
164// Update the registered submodule to match what the superproject expects, the
165// submodule should be initialized first calling the Init method or setting in
166// the options SubmoduleUpdateOptions.Init equals true
167func (s *Submodule) Update(o *SubmoduleUpdateOptions) error {
168	return s.UpdateContext(context.Background(), o)
169}
170
171// UpdateContext the registered submodule to match what the superproject
172// expects, the submodule should be initialized first calling the Init method or
173// setting in the options SubmoduleUpdateOptions.Init equals true.
174//
175// The provided Context must be non-nil. If the context expires before the
176// operation is complete, an error is returned. The context only affects the
177// transport operations.
178func (s *Submodule) UpdateContext(ctx context.Context, o *SubmoduleUpdateOptions) error {
179	return s.update(ctx, o, plumbing.ZeroHash)
180}
181
182func (s *Submodule) update(ctx context.Context, o *SubmoduleUpdateOptions, forceHash plumbing.Hash) error {
183	if !s.initialized && !o.Init {
184		return ErrSubmoduleNotInitialized
185	}
186
187	if !s.initialized && o.Init {
188		if err := s.Init(); err != nil {
189			return err
190		}
191	}
192
193	idx, err := s.w.r.Storer.Index()
194	if err != nil {
195		return err
196	}
197
198	hash := forceHash
199	if hash.IsZero() {
200		e, err := idx.Entry(s.c.Path)
201		if err != nil {
202			return err
203		}
204
205		hash = e.Hash
206	}
207
208	r, err := s.Repository()
209	if err != nil {
210		return err
211	}
212
213	if err := s.fetchAndCheckout(ctx, r, o, hash); err != nil {
214		return err
215	}
216
217	return s.doRecursiveUpdate(r, o)
218}
219
220func (s *Submodule) doRecursiveUpdate(r *Repository, o *SubmoduleUpdateOptions) error {
221	if o.RecurseSubmodules == NoRecurseSubmodules {
222		return nil
223	}
224
225	w, err := r.Worktree()
226	if err != nil {
227		return err
228	}
229
230	l, err := w.Submodules()
231	if err != nil {
232		return err
233	}
234
235	new := &SubmoduleUpdateOptions{}
236	*new = *o
237
238	new.RecurseSubmodules--
239	return l.Update(new)
240}
241
242func (s *Submodule) fetchAndCheckout(
243	ctx context.Context, r *Repository, o *SubmoduleUpdateOptions, hash plumbing.Hash,
244) error {
245	if !o.NoFetch {
246		err := r.FetchContext(ctx, &FetchOptions{Auth: o.Auth})
247		if err != nil && err != NoErrAlreadyUpToDate {
248			return err
249		}
250	}
251
252	w, err := r.Worktree()
253	if err != nil {
254		return err
255	}
256
257	// Handle a case when submodule refers to an orphaned commit that's still reachable
258	// through Git server using a special protocol capability[1].
259	//
260	// [1]: https://git-scm.com/docs/protocol-capabilities#_allow_reachable_sha1_in_want
261	if !o.NoFetch {
262		if _, err := w.r.Object(plumbing.AnyObject, hash); err != nil {
263			refSpec := config.RefSpec("+" + hash.String() + ":" + hash.String())
264
265			err := r.FetchContext(ctx, &FetchOptions{
266				Auth:     o.Auth,
267				RefSpecs: []config.RefSpec{refSpec},
268			})
269			if err != nil && err != NoErrAlreadyUpToDate && err != ErrExactSHA1NotSupported {
270				return err
271			}
272		}
273	}
274
275	if err := w.Checkout(&CheckoutOptions{Hash: hash}); err != nil {
276		return err
277	}
278
279	head := plumbing.NewHashReference(plumbing.HEAD, hash)
280	return r.Storer.SetReference(head)
281}
282
283// Submodules list of several submodules from the same repository.
284type Submodules []*Submodule
285
286// Init initializes the submodules in this list.
287func (s Submodules) Init() error {
288	for _, sub := range s {
289		if err := sub.Init(); err != nil {
290			return err
291		}
292	}
293
294	return nil
295}
296
297// Update updates all the submodules in this list.
298func (s Submodules) Update(o *SubmoduleUpdateOptions) error {
299	return s.UpdateContext(context.Background(), o)
300}
301
302// UpdateContext updates all the submodules in this list.
303//
304// The provided Context must be non-nil. If the context expires before the
305// operation is complete, an error is returned. The context only affects the
306// transport operations.
307func (s Submodules) UpdateContext(ctx context.Context, o *SubmoduleUpdateOptions) error {
308	for _, sub := range s {
309		if err := sub.UpdateContext(ctx, o); err != nil {
310			return err
311		}
312	}
313
314	return nil
315}
316
317// Status returns the status of the submodules.
318func (s Submodules) Status() (SubmodulesStatus, error) {
319	var list SubmodulesStatus
320
321	var r *Repository
322	for _, sub := range s {
323		if r == nil {
324			r = sub.w.r
325		}
326
327		idx, err := r.Storer.Index()
328		if err != nil {
329			return nil, err
330		}
331
332		status, err := sub.status(idx)
333		if err != nil {
334			return nil, err
335		}
336
337		list = append(list, status)
338	}
339
340	return list, nil
341}
342
343// SubmodulesStatus contains the status for all submodiles in the worktree
344type SubmodulesStatus []*SubmoduleStatus
345
346// String is equivalent to `git submodule status`
347func (s SubmodulesStatus) String() string {
348	buf := bytes.NewBuffer(nil)
349	for _, sub := range s {
350		fmt.Fprintln(buf, sub)
351	}
352
353	return buf.String()
354}
355
356// SubmoduleStatus contains the status for a submodule in the worktree
357type SubmoduleStatus struct {
358	Path     string
359	Current  plumbing.Hash
360	Expected plumbing.Hash
361	Branch   plumbing.ReferenceName
362}
363
364// IsClean is the HEAD of the submodule is equals to the expected commit
365func (s *SubmoduleStatus) IsClean() bool {
366	return s.Current == s.Expected
367}
368
369// String is equivalent to `git submodule status <submodule>`
370//
371// This will print the SHA-1 of the currently checked out commit for a
372// submodule, along with the submodule path and the output of git describe fo
373// the SHA-1. Each SHA-1 will be prefixed with - if the submodule is not
374// initialized, + if the currently checked out submodule commit does not match
375// the SHA-1 found in the index of the containing repository.
376func (s *SubmoduleStatus) String() string {
377	var extra string
378	var status = ' '
379
380	if s.Current.IsZero() {
381		status = '-'
382	} else if !s.IsClean() {
383		status = '+'
384	}
385
386	if len(s.Branch) != 0 {
387		extra = string(s.Branch[5:])
388	} else if !s.Current.IsZero() {
389		extra = s.Current.String()[:7]
390	}
391
392	if extra != "" {
393		extra = fmt.Sprintf(" (%s)", extra)
394	}
395
396	return fmt.Sprintf("%c%s %s%s", status, s.Expected, s.Path, extra)
397}
398