1package repository
2
3import (
4 "bufio"
5 "bytes"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "sort"
12 "strings"
13 "sync"
14 "time"
15
16 "github.com/ProtonMail/go-crypto/openpgp"
17 "github.com/go-git/go-billy/v5/osfs"
18 gogit "github.com/go-git/go-git/v5"
19 "github.com/go-git/go-git/v5/config"
20 "github.com/go-git/go-git/v5/plumbing"
21 "github.com/go-git/go-git/v5/plumbing/filemode"
22 "github.com/go-git/go-git/v5/plumbing/object"
23 "golang.org/x/sync/errgroup"
24 "golang.org/x/sys/execabs"
25
26 "github.com/git-bug/git-bug/util/lamport"
27)
28
29const clockPath = "clocks"
30const indexPath = "indexes"
31
32var _ ClockedRepo = &GoGitRepo{}
33var _ TestedRepo = &GoGitRepo{}
34
35type GoGitRepo struct {
36 // Unfortunately, some parts of go-git are not thread-safe so we have to cover them with a big fat mutex here.
37 // See https://github.com/go-git/go-git/issues/48
38 // See https://github.com/go-git/go-git/issues/208
39 // See https://github.com/go-git/go-git/pull/186
40 rMutex sync.Mutex
41 r *gogit.Repository
42 path string
43
44 clocksMutex sync.Mutex
45 clocks map[string]lamport.Clock
46
47 indexesMutex sync.Mutex
48 indexes map[string]Index
49
50 keyring Keyring
51 localStorage LocalStorage
52}
53
54// OpenGoGitRepo opens an already existing repo at the given path and
55// with the specified LocalStorage namespace. Given a repository path
56// of "~/myrepo" and a namespace of "git-bug", local storage for the
57// GoGitRepo will be configured at "~/myrepo/.git/git-bug".
58func OpenGoGitRepo(path, namespace string, clockLoaders []ClockLoader) (*GoGitRepo, error) {
59 path, err := detectGitPath(path, 0)
60 if err != nil {
61 return nil, err
62 }
63
64 r, err := gogit.PlainOpen(path)
65 if err != nil {
66 return nil, err
67 }
68
69 k, err := defaultKeyring()
70 if err != nil {
71 return nil, err
72 }
73
74 repo := &GoGitRepo{
75 r: r,
76 path: path,
77 clocks: make(map[string]lamport.Clock),
78 indexes: make(map[string]Index),
79 keyring: k,
80 localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
81 }
82
83 loaderToRun := make([]ClockLoader, 0, len(clockLoaders))
84 for _, loader := range clockLoaders {
85 loader := loader
86 allExist := true
87 for _, name := range loader.Clocks {
88 if _, err := repo.getClock(name); err != nil {
89 allExist = false
90 }
91 }
92
93 if !allExist {
94 loaderToRun = append(loaderToRun, loader)
95 }
96 }
97
98 var errG errgroup.Group
99 for _, loader := range loaderToRun {
100 loader := loader
101 errG.Go(func() error {
102 return loader.Witnesser(repo)
103 })
104 }
105 err = errG.Wait()
106 if err != nil {
107 return nil, err
108 }
109
110 return repo, nil
111}
112
113// InitGoGitRepo creates a new empty git repo at the given path and
114// with the specified LocalStorage namespace. Given a repository path
115// of "~/myrepo" and a namespace of "git-bug", local storage for the
116// GoGitRepo will be configured at "~/myrepo/.git/git-bug".
117func InitGoGitRepo(path, namespace string) (*GoGitRepo, error) {
118 r, err := gogit.PlainInit(path, false)
119 if err != nil {
120 return nil, err
121 }
122
123 k, err := defaultKeyring()
124 if err != nil {
125 return nil, err
126 }
127
128 return &GoGitRepo{
129 r: r,
130 path: filepath.Join(path, ".git"),
131 clocks: make(map[string]lamport.Clock),
132 indexes: make(map[string]Index),
133 keyring: k,
134 localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, ".git", namespace))},
135 }, nil
136}
137
138// InitBareGoGitRepo creates a new --bare empty git repo at the given
139// path and with the specified LocalStorage namespace. Given a repository
140// path of "~/myrepo" and a namespace of "git-bug", local storage for the
141// GoGitRepo will be configured at "~/myrepo/.git/git-bug".
142func InitBareGoGitRepo(path, namespace string) (*GoGitRepo, error) {
143 r, err := gogit.PlainInit(path, true)
144 if err != nil {
145 return nil, err
146 }
147
148 k, err := defaultKeyring()
149 if err != nil {
150 return nil, err
151 }
152
153 return &GoGitRepo{
154 r: r,
155 path: path,
156 clocks: make(map[string]lamport.Clock),
157 indexes: make(map[string]Index),
158 keyring: k,
159 localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
160 }, nil
161}
162
163func detectGitPath(path string, depth int) (string, error) {
164 if depth >= 10 {
165 return "", fmt.Errorf("gitdir loop detected")
166 }
167
168 // normalize the path
169 path, err := filepath.Abs(path)
170 if err != nil {
171 return "", err
172 }
173
174 for {
175 fi, err := os.Stat(filepath.Join(path, ".git"))
176 if err == nil {
177 if !fi.IsDir() {
178 // See if our .git item is a dotfile that holds a submodule reference
179 dotfile, err := os.Open(filepath.Join(path, fi.Name()))
180 if err != nil {
181 // Can't open error
182 return "", fmt.Errorf(".git exists but is not a directory or a readable file: %w", err)
183 }
184 // We aren't going to defer the dotfile.Close, because we might keep looping, so we have to be sure to
185 // clean up before returning an error
186 reader := bufio.NewReader(io.LimitReader(dotfile, 2048))
187 line, _, err := reader.ReadLine()
188 _ = dotfile.Close()
189 if err != nil {
190 return "", fmt.Errorf(".git exists but is not a directory and cannot be read: %w", err)
191 }
192 dotContent := string(line)
193 if strings.HasPrefix(dotContent, "gitdir:") {
194 // This is a submodule parent path link. Strip the prefix, clean the string of whitespace just to
195 // be safe, and return
196 dotContent = strings.TrimSpace(strings.TrimPrefix(dotContent, "gitdir: "))
197 p, err := detectGitPath(dotContent, depth+1)
198 if err != nil {
199 return "", fmt.Errorf(".git gitdir error: %w", err)
200 }
201 return p, nil
202 }
203 return "", fmt.Errorf(".git exist but is not a directory or module/workspace file")
204 }
205 return filepath.Join(path, ".git"), nil
206 }
207 if !os.IsNotExist(err) {
208 // unknown error
209 return "", err
210 }
211
212 // detect bare repo
213 ok, err := isGitDir(path)
214 if err != nil {
215 return "", err
216 }
217 if ok {
218 return path, nil
219 }
220
221 if parent := filepath.Dir(path); parent == path {
222 return "", fmt.Errorf(".git not found")
223 } else {
224 path = parent
225 }
226 }
227}
228
229func isGitDir(path string) (bool, error) {
230 markers := []string{"HEAD", "objects", "refs"}
231
232 for _, marker := range markers {
233 _, err := os.Stat(filepath.Join(path, marker))
234 if err == nil {
235 continue
236 }
237 if !os.IsNotExist(err) {
238 // unknown error
239 return false, err
240 } else {
241 return false, nil
242 }
243 }
244
245 return true, nil
246}
247
248func (repo *GoGitRepo) Close() error {
249 var firstErr error
250 for name, index := range repo.indexes {
251 err := index.Close()
252 if err != nil && firstErr == nil {
253 firstErr = err
254 }
255 delete(repo.indexes, name)
256 }
257 return firstErr
258}
259
260// LocalConfig give access to the repository scoped configuration
261func (repo *GoGitRepo) LocalConfig() Config {
262 return newGoGitLocalConfig(repo.r)
263}
264
265// GlobalConfig give access to the global scoped configuration
266func (repo *GoGitRepo) GlobalConfig() Config {
267 return newGoGitGlobalConfig()
268}
269
270// AnyConfig give access to a merged local/global configuration
271func (repo *GoGitRepo) AnyConfig() ConfigRead {
272 return mergeConfig(repo.LocalConfig(), repo.GlobalConfig())
273}
274
275// Keyring give access to a user-wide storage for secrets
276func (repo *GoGitRepo) Keyring() Keyring {
277 return repo.keyring
278}
279
280// GetUserName returns the name the user has used to configure git
281func (repo *GoGitRepo) GetUserName() (string, error) {
282 return repo.AnyConfig().ReadString("user.name")
283}
284
285// GetUserEmail returns the email address that the user has used to configure git.
286func (repo *GoGitRepo) GetUserEmail() (string, error) {
287 return repo.AnyConfig().ReadString("user.email")
288}
289
290// GetCoreEditor returns the name of the editor that the user has used to configure git.
291func (repo *GoGitRepo) GetCoreEditor() (string, error) {
292 // See https://git-scm.com/docs/git-var
293 // The order of preference is the $GIT_EDITOR environment variable, then core.editor configuration, then $VISUAL, then $EDITOR, and then the default chosen at compile time, which is usually vi.
294
295 if val, ok := os.LookupEnv("GIT_EDITOR"); ok {
296 return val, nil
297 }
298
299 val, err := repo.AnyConfig().ReadString("core.editor")
300 if err == nil && val != "" {
301 return val, nil
302 }
303 if err != nil && !errors.Is(err, ErrNoConfigEntry) {
304 return "", err
305 }
306
307 if val, ok := os.LookupEnv("VISUAL"); ok {
308 return val, nil
309 }
310
311 if val, ok := os.LookupEnv("EDITOR"); ok {
312 return val, nil
313 }
314
315 priorities := []string{
316 "editor",
317 "nano",
318 "vim",
319 "vi",
320 "emacs",
321 }
322
323 for _, cmd := range priorities {
324 if _, err = execabs.LookPath(cmd); err == nil {
325 return cmd, nil
326 }
327
328 }
329
330 return "ed", nil
331}
332
333// GetRemotes returns the configured remotes repositories.
334func (repo *GoGitRepo) GetRemotes() (map[string]string, error) {
335 cfg, err := repo.r.Config()
336 if err != nil {
337 return nil, err
338 }
339
340 result := make(map[string]string, len(cfg.Remotes))
341 for name, remote := range cfg.Remotes {
342 if len(remote.URLs) > 0 {
343 result[name] = remote.URLs[0]
344 }
345 }
346
347 return result, nil
348}
349
350// LocalStorage returns a billy.Filesystem giving access to
351// $RepoPath/.git/$Namespace.
352func (repo *GoGitRepo) LocalStorage() LocalStorage {
353 return repo.localStorage
354}
355
356func (repo *GoGitRepo) GetIndex(name string) (Index, error) {
357 repo.indexesMutex.Lock()
358 defer repo.indexesMutex.Unlock()
359
360 if index, ok := repo.indexes[name]; ok {
361 return index, nil
362 }
363
364 path := filepath.Join(repo.localStorage.Root(), indexPath, name)
365
366 index, err := openBleveIndex(path)
367 if err == nil {
368 repo.indexes[name] = index
369 }
370 return index, err
371}
372
373// FetchRefs fetch git refs matching a directory prefix to a remote
374// Ex: prefix="foo" will fetch any remote refs matching "refs/foo/*" locally.
375// The equivalent git refspec would be "refs/foo/*:refs/remotes/<remote>/foo/*"
376func (repo *GoGitRepo) FetchRefs(remote string, prefixes ...string) (string, error) {
377 refSpecs := make([]config.RefSpec, len(prefixes))
378
379 for i, prefix := range prefixes {
380 refSpecs[i] = config.RefSpec(fmt.Sprintf("refs/%s/*:refs/remotes/%s/%s/*", prefix, remote, prefix))
381 }
382
383 buf := bytes.NewBuffer(nil)
384
385 remoteUrl, err := repo.resolveRemote(remote, true)
386 if err != nil {
387 return "", err
388 }
389
390 err = repo.r.Fetch(&gogit.FetchOptions{
391 RemoteName: remote,
392 RemoteURL: remoteUrl,
393 RefSpecs: refSpecs,
394 Progress: buf,
395 })
396 if err == gogit.NoErrAlreadyUpToDate {
397 return "already up-to-date", nil
398 }
399 if err != nil {
400 return "", err
401 }
402
403 return buf.String(), nil
404}
405
406// resolveRemote returns the URI for a given remote
407func (repo *GoGitRepo) resolveRemote(remote string, fetch bool) (string, error) {
408 cfg, err := repo.r.ConfigScoped(config.SystemScope)
409 if err != nil {
410 return "", fmt.Errorf("unable to load system-scoped git config: %v", err)
411 }
412
413 var url string
414 for _, re := range cfg.Remotes {
415 if remote == re.Name {
416 // url is set matching the default logic in go-git's repository.Push
417 // and repository.Fetch logic as of go-git v5.12.1.
418 //
419 // we do this because the push and fetch methods can only take one
420 // remote for both option structs, even though the push method
421 // _should_ push to all of the URLs defined for a given remote.
422 url = re.URLs[len(re.URLs)-1]
423 if fetch {
424 url = re.URLs[0]
425 }
426
427 for _, u := range cfg.URLs {
428 if strings.HasPrefix(url, u.InsteadOf) {
429 url = u.ApplyInsteadOf(url)
430 break
431 }
432 }
433 }
434 }
435
436 if url == "" {
437 return "", fmt.Errorf("unable to resolve URL for remote: %v", err)
438 }
439
440 return url, nil
441}
442
443// PushRefs push git refs matching a directory prefix to a remote
444// Ex: prefix="foo" will push any local refs matching "refs/foo/*" to the remote.
445// The equivalent git refspec would be "refs/foo/*:refs/foo/*"
446//
447// Additionally, PushRefs will update the local references in refs/remotes/<remote>/foo to match
448// the remote state.
449func (repo *GoGitRepo) PushRefs(remote string, prefixes ...string) (string, error) {
450 remo, err := repo.r.Remote(remote)
451 if err != nil {
452 return "", err
453 }
454
455 refSpecs := make([]config.RefSpec, len(prefixes))
456
457 for i, prefix := range prefixes {
458 refspec := fmt.Sprintf("refs/%s/*:refs/%s/*", prefix, prefix)
459
460 // to make sure that the push also create the corresponding refs/remotes/<remote>/... references,
461 // we need to have a default fetch refspec configured on the remote, to make our refs "track" the remote ones.
462 // This does not change the config on disk, only on memory.
463 hasCustomFetch := false
464 fetchRefspec := fmt.Sprintf("refs/%s/*:refs/remotes/%s/%s/*", prefix, remote, prefix)
465 for _, r := range remo.Config().Fetch {
466 if string(r) == fetchRefspec {
467 hasCustomFetch = true
468 break
469 }
470 }
471
472 if !hasCustomFetch {
473 remo.Config().Fetch = append(remo.Config().Fetch, config.RefSpec(fetchRefspec))
474 }
475
476 refSpecs[i] = config.RefSpec(refspec)
477 }
478
479 buf := bytes.NewBuffer(nil)
480
481 remoteUrl, err := repo.resolveRemote(remote, false)
482 if err != nil {
483 return "", err
484 }
485
486 err = remo.Push(&gogit.PushOptions{
487 RemoteName: remote,
488 RemoteURL: remoteUrl,
489 RefSpecs: refSpecs,
490 Progress: buf,
491 })
492 if err == gogit.NoErrAlreadyUpToDate {
493 return "already up-to-date", nil
494 }
495 if err != nil {
496 return "", err
497 }
498
499 return buf.String(), nil
500}
501
502// StoreData will store arbitrary data and return the corresponding hash
503func (repo *GoGitRepo) StoreData(data []byte) (Hash, error) {
504 obj := repo.r.Storer.NewEncodedObject()
505 obj.SetType(plumbing.BlobObject)
506
507 w, err := obj.Writer()
508 if err != nil {
509 return "", err
510 }
511
512 _, err = w.Write(data)
513 if err != nil {
514 return "", err
515 }
516
517 h, err := repo.r.Storer.SetEncodedObject(obj)
518 if err != nil {
519 return "", err
520 }
521
522 return Hash(h.String()), nil
523}
524
525// ReadData will attempt to read arbitrary data from the given hash
526func (repo *GoGitRepo) ReadData(hash Hash) ([]byte, error) {
527 repo.rMutex.Lock()
528 defer repo.rMutex.Unlock()
529
530 obj, err := repo.r.BlobObject(plumbing.NewHash(hash.String()))
531 if err == plumbing.ErrObjectNotFound {
532 return nil, ErrNotFound
533 }
534 if err != nil {
535 return nil, err
536 }
537
538 r, err := obj.Reader()
539 if err != nil {
540 return nil, err
541 }
542
543 // TODO: return a io.Reader instead
544 return io.ReadAll(r)
545}
546
547// StoreTree will store a mapping key-->Hash as a Git tree
548func (repo *GoGitRepo) StoreTree(mapping []TreeEntry) (Hash, error) {
549 var tree object.Tree
550
551 // TODO: can be removed once https://github.com/go-git/go-git/issues/193 is resolved
552 sorted := make([]TreeEntry, len(mapping))
553 copy(sorted, mapping)
554 sort.Slice(sorted, func(i, j int) bool {
555 nameI := sorted[i].Name
556 if sorted[i].ObjectType == Tree {
557 nameI += "/"
558 }
559 nameJ := sorted[j].Name
560 if sorted[j].ObjectType == Tree {
561 nameJ += "/"
562 }
563 return nameI < nameJ
564 })
565
566 for _, entry := range sorted {
567 mode := filemode.Regular
568 if entry.ObjectType == Tree {
569 mode = filemode.Dir
570 }
571
572 tree.Entries = append(tree.Entries, object.TreeEntry{
573 Name: entry.Name,
574 Mode: mode,
575 Hash: plumbing.NewHash(entry.Hash.String()),
576 })
577 }
578
579 obj := repo.r.Storer.NewEncodedObject()
580 obj.SetType(plumbing.TreeObject)
581 err := tree.Encode(obj)
582 if err != nil {
583 return "", err
584 }
585
586 hash, err := repo.r.Storer.SetEncodedObject(obj)
587 if err != nil {
588 return "", err
589 }
590
591 return Hash(hash.String()), nil
592}
593
594// ReadTree will return the list of entries in a Git tree
595func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) {
596 repo.rMutex.Lock()
597 defer repo.rMutex.Unlock()
598
599 h := plumbing.NewHash(hash.String())
600
601 // the given hash could be a tree or a commit
602 obj, err := repo.r.Storer.EncodedObject(plumbing.AnyObject, h)
603 if err == plumbing.ErrObjectNotFound {
604 return nil, ErrNotFound
605 }
606 if err != nil {
607 return nil, err
608 }
609
610 var tree *object.Tree
611 switch obj.Type() {
612 case plumbing.TreeObject:
613 tree, err = object.DecodeTree(repo.r.Storer, obj)
614 case plumbing.CommitObject:
615 var commit *object.Commit
616 commit, err = object.DecodeCommit(repo.r.Storer, obj)
617 if err != nil {
618 return nil, err
619 }
620 tree, err = commit.Tree()
621 default:
622 return nil, fmt.Errorf("given hash is not a tree")
623 }
624 if err != nil {
625 return nil, err
626 }
627
628 treeEntries := make([]TreeEntry, len(tree.Entries))
629 for i, entry := range tree.Entries {
630 objType := Blob
631 if entry.Mode == filemode.Dir {
632 objType = Tree
633 }
634
635 treeEntries[i] = TreeEntry{
636 ObjectType: objType,
637 Hash: Hash(entry.Hash.String()),
638 Name: entry.Name,
639 }
640 }
641
642 return treeEntries, nil
643}
644
645// StoreCommit will store a Git commit with the given Git tree
646func (repo *GoGitRepo) StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) {
647 return repo.StoreSignedCommit(treeHash, nil, parents...)
648}
649
650// StoreSignedCommit will store a Git commit with the given Git tree. If signKey is not nil, the commit
651// will be signed accordingly.
652func (repo *GoGitRepo) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) {
653 cfg, err := repo.r.Config()
654 if err != nil {
655 return "", err
656 }
657
658 commit := object.Commit{
659 Author: object.Signature{
660 Name: cfg.Author.Name,
661 Email: cfg.Author.Email,
662 When: time.Now(),
663 },
664 Committer: object.Signature{
665 Name: cfg.Committer.Name,
666 Email: cfg.Committer.Email,
667 When: time.Now(),
668 },
669 Message: "",
670 TreeHash: plumbing.NewHash(treeHash.String()),
671 }
672
673 for _, parent := range parents {
674 commit.ParentHashes = append(commit.ParentHashes, plumbing.NewHash(parent.String()))
675 }
676
677 // Compute the signature if needed
678 if signKey != nil {
679 // first get the serialized commit
680 encoded := &plumbing.MemoryObject{}
681 if err := commit.Encode(encoded); err != nil {
682 return "", err
683 }
684 r, err := encoded.Reader()
685 if err != nil {
686 return "", err
687 }
688
689 // sign the data
690 var sig bytes.Buffer
691 if err := openpgp.ArmoredDetachSign(&sig, signKey, r, nil); err != nil {
692 return "", err
693 }
694 commit.PGPSignature = sig.String()
695 }
696
697 obj := repo.r.Storer.NewEncodedObject()
698 obj.SetType(plumbing.CommitObject)
699 err = commit.Encode(obj)
700 if err != nil {
701 return "", err
702 }
703
704 hash, err := repo.r.Storer.SetEncodedObject(obj)
705 if err != nil {
706 return "", err
707 }
708
709 return Hash(hash.String()), nil
710}
711
712func (repo *GoGitRepo) ResolveRef(ref string) (Hash, error) {
713 r, err := repo.r.Reference(plumbing.ReferenceName(ref), false)
714 if err == plumbing.ErrReferenceNotFound {
715 return "", ErrNotFound
716 }
717 if err != nil {
718 return "", err
719 }
720 return Hash(r.Hash().String()), nil
721}
722
723// UpdateRef will create or update a Git reference
724func (repo *GoGitRepo) UpdateRef(ref string, hash Hash) error {
725 return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(ref), plumbing.NewHash(hash.String())))
726}
727
728// RemoveRef will remove a Git reference
729func (repo *GoGitRepo) RemoveRef(ref string) error {
730 return repo.r.Storer.RemoveReference(plumbing.ReferenceName(ref))
731}
732
733// ListRefs will return a list of Git ref matching the given refspec
734func (repo *GoGitRepo) ListRefs(refPrefix string) ([]string, error) {
735 refIter, err := repo.r.References()
736 if err != nil {
737 return nil, err
738 }
739
740 refs := make([]string, 0)
741
742 err = refIter.ForEach(func(ref *plumbing.Reference) error {
743 if strings.HasPrefix(ref.Name().String(), refPrefix) {
744 refs = append(refs, ref.Name().String())
745 }
746 return nil
747 })
748 if err != nil {
749 return nil, err
750 }
751
752 return refs, nil
753}
754
755// RefExist will check if a reference exist in Git
756func (repo *GoGitRepo) RefExist(ref string) (bool, error) {
757 _, err := repo.r.Reference(plumbing.ReferenceName(ref), false)
758 if err == nil {
759 return true, nil
760 } else if err == plumbing.ErrReferenceNotFound {
761 return false, nil
762 }
763 return false, err
764}
765
766// CopyRef will create a new reference with the same value as another one
767func (repo *GoGitRepo) CopyRef(source string, dest string) error {
768 r, err := repo.r.Reference(plumbing.ReferenceName(source), false)
769 if err == plumbing.ErrReferenceNotFound {
770 return ErrNotFound
771 }
772 if err != nil {
773 return err
774 }
775 return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(dest), r.Hash()))
776}
777
778// ListCommits will return the list of tree hashes of a ref, in chronological order
779func (repo *GoGitRepo) ListCommits(ref string) ([]Hash, error) {
780 return nonNativeListCommits(repo, ref)
781}
782
783func (repo *GoGitRepo) ReadCommit(hash Hash) (Commit, error) {
784 repo.rMutex.Lock()
785 defer repo.rMutex.Unlock()
786
787 commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
788 if err == plumbing.ErrObjectNotFound {
789 return Commit{}, ErrNotFound
790 }
791 if err != nil {
792 return Commit{}, err
793 }
794
795 parents := make([]Hash, len(commit.ParentHashes))
796 for i, parentHash := range commit.ParentHashes {
797 parents[i] = Hash(parentHash.String())
798 }
799
800 result := Commit{
801 Hash: hash,
802 Parents: parents,
803 TreeHash: Hash(commit.TreeHash.String()),
804 }
805
806 if commit.PGPSignature != "" {
807 // I can't find a way to just remove the signature when reading the encoded commit so we need to
808 // re-encode the commit without signature.
809
810 encoded := &plumbing.MemoryObject{}
811 err := commit.EncodeWithoutSignature(encoded)
812 if err != nil {
813 return Commit{}, err
814 }
815
816 result.SignedData, err = encoded.Reader()
817 if err != nil {
818 return Commit{}, err
819 }
820
821 result.Signature, err = deArmorSignature(strings.NewReader(commit.PGPSignature))
822 if err != nil {
823 return Commit{}, err
824 }
825 }
826
827 return result, nil
828}
829
830var _ RepoBrowse = &GoGitRepo{}
831
832func (repo *GoGitRepo) GetDefaultBranch() (string, error) {
833 repo.rMutex.Lock()
834 defer repo.rMutex.Unlock()
835
836 head, err := repo.r.Head()
837 if err != nil {
838 return "main", nil // sensible fallback for detached HEAD
839 }
840 return head.Name().Short(), nil
841}
842
843func (repo *GoGitRepo) ReadCommitMeta(hash Hash) (CommitMeta, error) {
844 repo.rMutex.Lock()
845 defer repo.rMutex.Unlock()
846
847 commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
848 if err == plumbing.ErrObjectNotFound {
849 return CommitMeta{}, ErrNotFound
850 }
851 if err != nil {
852 return CommitMeta{}, err
853 }
854
855 return commitToMeta(commit), nil
856}
857
858func (repo *GoGitRepo) CommitLog(ref string, path string, limit int, after Hash) ([]CommitMeta, error) {
859 repo.rMutex.Lock()
860 defer repo.rMutex.Unlock()
861
862 h, err := repo.resolveShortRef(ref)
863 if err != nil {
864 return nil, err
865 }
866
867 opts := &gogit.LogOptions{From: h}
868 if path != "" {
869 opts.PathFilter = func(p string) bool {
870 return p == path || strings.HasPrefix(p, path+"/")
871 }
872 // PathFilter requires OrderCommitterTime for correct results
873 opts.Order = gogit.LogOrderCommitterTime
874 }
875
876 iter, err := repo.r.Log(opts)
877 if err != nil {
878 return nil, err
879 }
880 defer iter.Close()
881
882 var commits []CommitMeta
883 skipping := after != ""
884
885 for {
886 if len(commits) >= limit {
887 break
888 }
889 commit, err := iter.Next()
890 if err == io.EOF {
891 break
892 }
893 if err != nil {
894 return nil, err
895 }
896 if skipping {
897 if Hash(commit.Hash.String()) == after {
898 skipping = false
899 }
900 continue
901 }
902 commits = append(commits, commitToMeta(commit))
903 }
904
905 return commits, nil
906}
907
908// resolveShortRef resolves a short branch/tag name or full ref to a commit hash.
909// Must be called with rMutex held.
910func (repo *GoGitRepo) resolveShortRef(ref string) (plumbing.Hash, error) {
911 // Try as full ref first, then refs/heads/, refs/tags/, then raw hash.
912 for _, prefix := range []string{"", "refs/heads/", "refs/tags/"} {
913 r, err := repo.r.Reference(plumbing.ReferenceName(prefix+ref), true)
914 if err == nil {
915 return r.Hash(), nil
916 }
917 }
918 // Fall back to treating it as a commit hash directly.
919 h := plumbing.NewHash(ref)
920 if !h.IsZero() {
921 return h, nil
922 }
923 return plumbing.ZeroHash, fmt.Errorf("cannot resolve ref %q", ref)
924}
925
926func commitToMeta(c *object.Commit) CommitMeta {
927 msg := strings.TrimSpace(c.Message)
928 if i := strings.IndexByte(msg, '\n'); i >= 0 {
929 msg = msg[:i]
930 }
931 parents := make([]Hash, len(c.ParentHashes))
932 for i, p := range c.ParentHashes {
933 parents[i] = Hash(p.String())
934 }
935 h := Hash(c.Hash.String())
936 return CommitMeta{
937 Hash: h,
938 ShortHash: h.String()[:7],
939 Message: msg,
940 AuthorName: c.Author.Name,
941 AuthorEmail: c.Author.Email,
942 Date: c.Author.When,
943 Parents: parents,
944 }
945}
946
947// LastCommitForEntries walks the commit history once (newest-first) and returns
948// the most recent commit that modified each named entry in dirPath.
949//
950// Instead of computing recursive tree diffs, it reads only the shallow tree at
951// dirPath for consecutive commits and compares entry hashes directly. This is
952// O(commits × entries) with cheap hash comparisons rather than O(commits × all
953// changed files in repo).
954func (repo *GoGitRepo) LastCommitForEntries(ref string, dirPath string, names []string) (map[string]CommitMeta, error) {
955 repo.rMutex.Lock()
956 defer repo.rMutex.Unlock()
957
958 h, err := repo.resolveShortRef(ref)
959 if err != nil {
960 return nil, err
961 }
962
963 result := make(map[string]CommitMeta, len(names))
964 if len(names) == 0 {
965 return result, nil
966 }
967
968 // Build lookup set for fast membership test.
969 want := make(map[string]bool, len(names))
970 for _, n := range names {
971 want[n] = true
972 }
973
974 iter, err := repo.r.Log(&gogit.LogOptions{From: h, Order: gogit.LogOrderCommitterTime})
975 if err != nil {
976 return nil, err
977 }
978 defer iter.Close()
979
980 // dirHashes reads the entry hashes at dirPath for the given commit tree.
981 // Returns a map of entry name → blob/tree hash (shallow, no recursion).
982 dirHashes := func(tree *object.Tree) map[string]plumbing.Hash {
983 t := tree
984 if dirPath != "" {
985 sub, err := tree.Tree(dirPath)
986 if err != nil {
987 return nil
988 }
989 t = sub
990 }
991 m := make(map[string]plumbing.Hash, len(t.Entries))
992 for _, e := range t.Entries {
993 if want[e.Name] {
994 m[e.Name] = e.Hash
995 }
996 }
997 return m
998 }
999
1000 // Walk newest→oldest, comparing each commit's directory snapshot with the
1001 // previous (newer) commit's snapshot. When a hash differs, the newer commit
1002 // is the one that last changed that entry.
1003 var prevHashes map[string]plumbing.Hash
1004 var prevMeta CommitMeta
1005
1006 for len(result) < len(names) {
1007 commit, err := iter.Next()
1008 if err == io.EOF {
1009 break
1010 }
1011 if err != nil {
1012 return result, nil
1013 }
1014
1015 tree, err := commit.Tree()
1016 if err != nil {
1017 continue
1018 }
1019 currHashes := dirHashes(tree)
1020 meta := commitToMeta(commit)
1021
1022 if prevHashes != nil {
1023 for name := range want {
1024 if _, done := result[name]; done {
1025 continue
1026 }
1027 prev, inPrev := prevHashes[name]
1028 curr, inCurr := currHashes[name]
1029 // If the entry existed in prevHashes but differs (or is gone now),
1030 // the previous (newer) commit is when it was last changed.
1031 if inPrev && (!inCurr || prev != curr) {
1032 result[name] = prevMeta
1033 }
1034 }
1035 }
1036
1037 prevHashes = currHashes
1038 prevMeta = meta
1039 }
1040
1041 // Any names still present in prevHashes were last changed at the oldest
1042 // commit we reached (the entry existed there and we never saw it change).
1043 for name := range want {
1044 if _, done := result[name]; done {
1045 continue
1046 }
1047 if _, exists := prevHashes[name]; exists {
1048 result[name] = prevMeta
1049 }
1050 }
1051
1052 return result, nil
1053}
1054
1055// CommitDetail returns full metadata for a commit plus its changed files.
1056func (repo *GoGitRepo) CommitDetail(hash Hash) (CommitDetail, error) {
1057 repo.rMutex.Lock()
1058 defer repo.rMutex.Unlock()
1059
1060 commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
1061 if err == plumbing.ErrObjectNotFound {
1062 return CommitDetail{}, ErrNotFound
1063 }
1064 if err != nil {
1065 return CommitDetail{}, err
1066 }
1067
1068 detail := CommitDetail{
1069 CommitMeta: commitToMeta(commit),
1070 FullMessage: strings.TrimSpace(commit.Message),
1071 }
1072
1073 tree, err := commit.Tree()
1074 if err != nil {
1075 return detail, nil
1076 }
1077
1078 var parentTree *object.Tree
1079 if len(commit.ParentHashes) > 0 {
1080 if parent, err := commit.Parent(0); err == nil {
1081 parentTree, _ = parent.Tree()
1082 }
1083 }
1084 if parentTree == nil {
1085 parentTree = &object.Tree{}
1086 }
1087
1088 changes, err := object.DiffTree(parentTree, tree)
1089 if err != nil {
1090 return detail, nil
1091 }
1092
1093 for _, change := range changes {
1094 from, to := change.From.Name, change.To.Name
1095 var f ChangedFile
1096 switch {
1097 case from == "":
1098 f = ChangedFile{Path: to, Status: "added"}
1099 case to == "":
1100 f = ChangedFile{Path: from, Status: "deleted"}
1101 case from != to:
1102 f = ChangedFile{Path: to, OldPath: from, Status: "renamed"}
1103 default:
1104 f = ChangedFile{Path: to, Status: "modified"}
1105 }
1106 detail.Files = append(detail.Files, f)
1107 }
1108
1109 return detail, nil
1110}
1111
1112func (repo *GoGitRepo) AllClocks() (map[string]lamport.Clock, error) {
1113 repo.clocksMutex.Lock()
1114 defer repo.clocksMutex.Unlock()
1115
1116 result := make(map[string]lamport.Clock)
1117
1118 files, err := os.ReadDir(filepath.Join(repo.localStorage.Root(), clockPath))
1119 if os.IsNotExist(err) {
1120 return nil, nil
1121 }
1122 if err != nil {
1123 return nil, err
1124 }
1125
1126 for _, file := range files {
1127 name := file.Name()
1128 if c, ok := repo.clocks[name]; ok {
1129 result[name] = c
1130 } else {
1131 c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
1132 if err != nil {
1133 return nil, err
1134 }
1135 repo.clocks[name] = c
1136 result[name] = c
1137 }
1138 }
1139
1140 return result, nil
1141}
1142
1143// GetOrCreateClock return a Lamport clock stored in the Repo.
1144// If the clock doesn't exist, it's created.
1145func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) {
1146 repo.clocksMutex.Lock()
1147 defer repo.clocksMutex.Unlock()
1148
1149 c, err := repo.getClock(name)
1150 if err == nil {
1151 return c, nil
1152 }
1153 if err != ErrClockNotExist {
1154 return nil, err
1155 }
1156
1157 c, err = lamport.NewPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
1158 if err != nil {
1159 return nil, err
1160 }
1161
1162 repo.clocks[name] = c
1163 return c, nil
1164}
1165
1166func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) {
1167 if c, ok := repo.clocks[name]; ok {
1168 return c, nil
1169 }
1170
1171 c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
1172 if err == nil {
1173 repo.clocks[name] = c
1174 return c, nil
1175 }
1176 if err == lamport.ErrClockNotExist {
1177 return nil, ErrClockNotExist
1178 }
1179 return nil, err
1180}
1181
1182// Increment is equivalent to c = GetOrCreateClock(name) + c.Increment()
1183func (repo *GoGitRepo) Increment(name string) (lamport.Time, error) {
1184 c, err := repo.GetOrCreateClock(name)
1185 if err != nil {
1186 return lamport.Time(0), err
1187 }
1188 return c.Increment()
1189}
1190
1191// Witness is equivalent to c = GetOrCreateClock(name) + c.Witness(time)
1192func (repo *GoGitRepo) Witness(name string, time lamport.Time) error {
1193 c, err := repo.GetOrCreateClock(name)
1194 if err != nil {
1195 return err
1196 }
1197 return c.Witness(time)
1198}
1199
1200// AddRemote add a new remote to the repository
1201// Not in the interface because it's only used for testing
1202func (repo *GoGitRepo) AddRemote(name string, url string) error {
1203 _, err := repo.r.CreateRemote(&config.RemoteConfig{
1204 Name: name,
1205 URLs: []string{url},
1206 })
1207
1208 return err
1209}
1210
1211// GetLocalRemote return the URL to use to add this repo as a local remote
1212func (repo *GoGitRepo) GetLocalRemote() string {
1213 return repo.path
1214}
1215
1216// GetPath returns the root directory of the repository (strips the trailing
1217// /.git component so callers get the working-tree root, not the git dir).
1218func (repo *GoGitRepo) GetPath() string {
1219 return filepath.Clean(strings.TrimSuffix(repo.path, string(filepath.Separator)+".git"))
1220}
1221
1222// EraseFromDisk delete this repository entirely from the disk
1223func (repo *GoGitRepo) EraseFromDisk() error {
1224 err := repo.Close()
1225 if err != nil {
1226 return err
1227 }
1228
1229 path := filepath.Clean(strings.TrimSuffix(repo.path, string(filepath.Separator)+".git"))
1230
1231 // fmt.Println("Cleaning repo:", path)
1232 return os.RemoveAll(path)
1233}