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