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 fdiff "github.com/go-git/go-git/v5/plumbing/format/diff"
23 "github.com/go-git/go-git/v5/plumbing/object"
24 lru "github.com/hashicorp/golang-lru/v2"
25 "golang.org/x/sync/errgroup"
26 "golang.org/x/sys/execabs"
27
28 "github.com/git-bug/git-bug/util/lamport"
29)
30
31const clockPath = "clocks"
32const indexPath = "indexes"
33
34// lastCommitDepthLimit is the maximum number of commits walked by
35// LastCommitForEntries. Entries not found within this horizon are omitted from
36// the result rather than stalling the caller indefinitely.
37const lastCommitDepthLimit = 1000
38
39// lastCommitCacheSize is the number of (resolvedHash, dirPath) pairs kept in
40// the LRU cache for LastCommitForEntries. Each entry holds one CommitMeta per
41// directory entry (≈ a few KB for a typical directory), so 256 slots ≈ a few
42// MB of memory at most.
43const lastCommitCacheSize = 256
44
45var _ ClockedRepo = &GoGitRepo{}
46var _ TestedRepo = &GoGitRepo{}
47
48type GoGitRepo struct {
49 // Unfortunately, some parts of go-git are not thread-safe so we have to cover them with a big fat mutex here.
50 // See https://github.com/go-git/go-git/issues/48
51 // See https://github.com/go-git/go-git/issues/208
52 // See https://github.com/go-git/go-git/pull/186
53 rMutex sync.Mutex
54 r *gogit.Repository
55 path string
56
57 clocksMutex sync.Mutex
58 clocks map[string]lamport.Clock
59
60 indexesMutex sync.Mutex
61 indexes map[string]Index
62
63 // lastCommitCache caches LastCommitForEntries results keyed by
64 // "<treeHash>\x00<path>". Git trees are content-addressed and
65 // immutable, so entries never need invalidation and can be shared
66 // across refs that point to the same directory tree. The LRU bounds
67 // memory to lastCommitCacheSize unique (treeHash, directory) pairs.
68 lastCommitCache *lru.Cache[string, map[string]CommitMeta]
69
70 keyring Keyring
71 localStorage LocalStorage
72}
73
74// OpenGoGitRepo opens an already existing repo at the given path and
75// with the specified LocalStorage namespace. Given a repository path
76// of "~/myrepo" and a namespace of "git-bug", local storage for the
77// GoGitRepo will be configured at "~/myrepo/.git/git-bug".
78func OpenGoGitRepo(path, namespace string, clockLoaders []ClockLoader) (*GoGitRepo, error) {
79 path, err := detectGitPath(path, 0)
80 if err != nil {
81 return nil, err
82 }
83
84 r, err := gogit.PlainOpen(path)
85 if err != nil {
86 return nil, err
87 }
88
89 k, err := defaultKeyring()
90 if err != nil {
91 return nil, err
92 }
93
94 repo := &GoGitRepo{
95 r: r,
96 path: path,
97 clocks: make(map[string]lamport.Clock),
98 indexes: make(map[string]Index),
99 lastCommitCache: must(lru.New[string, map[string]CommitMeta](lastCommitCacheSize)),
100 keyring: k,
101 localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
102 }
103
104 loaderToRun := make([]ClockLoader, 0, len(clockLoaders))
105 for _, loader := range clockLoaders {
106 loader := loader
107 allExist := true
108 for _, name := range loader.Clocks {
109 if _, err := repo.getClock(name); err != nil {
110 allExist = false
111 }
112 }
113
114 if !allExist {
115 loaderToRun = append(loaderToRun, loader)
116 }
117 }
118
119 var errG errgroup.Group
120 for _, loader := range loaderToRun {
121 loader := loader
122 errG.Go(func() error {
123 return loader.Witnesser(repo)
124 })
125 }
126 err = errG.Wait()
127 if err != nil {
128 return nil, err
129 }
130
131 return repo, nil
132}
133
134// InitGoGitRepo creates a new empty git repo at the given path and
135// with the specified LocalStorage namespace. Given a repository path
136// of "~/myrepo" and a namespace of "git-bug", local storage for the
137// GoGitRepo will be configured at "~/myrepo/.git/git-bug".
138func InitGoGitRepo(path, namespace string) (*GoGitRepo, error) {
139 r, err := gogit.PlainInit(path, false)
140 if err != nil {
141 return nil, err
142 }
143
144 k, err := defaultKeyring()
145 if err != nil {
146 return nil, err
147 }
148
149 return &GoGitRepo{
150 r: r,
151 path: filepath.Join(path, ".git"),
152 clocks: make(map[string]lamport.Clock),
153 indexes: make(map[string]Index),
154 lastCommitCache: must(lru.New[string, map[string]CommitMeta](lastCommitCacheSize)),
155 keyring: k,
156 localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, ".git", namespace))},
157 }, nil
158}
159
160// InitBareGoGitRepo creates a new --bare empty git repo at the given
161// path and with the specified LocalStorage namespace. Given a repository
162// path of "~/myrepo" and a namespace of "git-bug", local storage for the
163// GoGitRepo will be configured at "~/myrepo/.git/git-bug".
164func InitBareGoGitRepo(path, namespace string) (*GoGitRepo, error) {
165 r, err := gogit.PlainInit(path, true)
166 if err != nil {
167 return nil, err
168 }
169
170 k, err := defaultKeyring()
171 if err != nil {
172 return nil, err
173 }
174
175 return &GoGitRepo{
176 r: r,
177 path: path,
178 clocks: make(map[string]lamport.Clock),
179 indexes: make(map[string]Index),
180 lastCommitCache: must(lru.New[string, map[string]CommitMeta](lastCommitCacheSize)),
181 keyring: k,
182 localStorage: billyLocalStorage{Filesystem: osfs.New(filepath.Join(path, namespace))},
183 }, nil
184}
185
186func detectGitPath(path string, depth int) (string, error) {
187 if depth >= 10 {
188 return "", fmt.Errorf("gitdir loop detected")
189 }
190
191 // normalize the path
192 path, err := filepath.Abs(path)
193 if err != nil {
194 return "", err
195 }
196
197 for {
198 fi, err := os.Stat(filepath.Join(path, ".git"))
199 if err == nil {
200 if !fi.IsDir() {
201 // See if our .git item is a dotfile that holds a submodule reference
202 dotfile, err := os.Open(filepath.Join(path, fi.Name()))
203 if err != nil {
204 // Can't open error
205 return "", fmt.Errorf(".git exists but is not a directory or a readable file: %w", err)
206 }
207 // We aren't going to defer the dotfile.Close, because we might keep looping, so we have to be sure to
208 // clean up before returning an error
209 reader := bufio.NewReader(io.LimitReader(dotfile, 2048))
210 line, _, err := reader.ReadLine()
211 _ = dotfile.Close()
212 if err != nil {
213 return "", fmt.Errorf(".git exists but is not a directory and cannot be read: %w", err)
214 }
215 dotContent := string(line)
216 if strings.HasPrefix(dotContent, "gitdir:") {
217 // This is a submodule parent path link. Strip the prefix, clean the string of whitespace just to
218 // be safe, and return
219 dotContent = strings.TrimSpace(strings.TrimPrefix(dotContent, "gitdir: "))
220 p, err := detectGitPath(dotContent, depth+1)
221 if err != nil {
222 return "", fmt.Errorf(".git gitdir error: %w", err)
223 }
224 return p, nil
225 }
226 return "", fmt.Errorf(".git exist but is not a directory or module/workspace file")
227 }
228 return filepath.Join(path, ".git"), nil
229 }
230 if !os.IsNotExist(err) {
231 // unknown error
232 return "", err
233 }
234
235 // detect bare repo
236 ok, err := isGitDir(path)
237 if err != nil {
238 return "", err
239 }
240 if ok {
241 return path, nil
242 }
243
244 if parent := filepath.Dir(path); parent == path {
245 return "", fmt.Errorf(".git not found")
246 } else {
247 path = parent
248 }
249 }
250}
251
252func isGitDir(path string) (bool, error) {
253 markers := []string{"HEAD", "objects", "refs"}
254
255 for _, marker := range markers {
256 _, err := os.Stat(filepath.Join(path, marker))
257 if err == nil {
258 continue
259 }
260 if !os.IsNotExist(err) {
261 // unknown error
262 return false, err
263 } else {
264 return false, nil
265 }
266 }
267
268 return true, nil
269}
270
271func (repo *GoGitRepo) Close() error {
272 var firstErr error
273 for name, index := range repo.indexes {
274 err := index.Close()
275 if err != nil && firstErr == nil {
276 firstErr = err
277 }
278 delete(repo.indexes, name)
279 }
280 return firstErr
281}
282
283// LocalConfig give access to the repository scoped configuration
284func (repo *GoGitRepo) LocalConfig() Config {
285 return newGoGitLocalConfig(repo.r)
286}
287
288// GlobalConfig give access to the global scoped configuration
289func (repo *GoGitRepo) GlobalConfig() Config {
290 return newGoGitGlobalConfig()
291}
292
293// AnyConfig give access to a merged local/global configuration
294func (repo *GoGitRepo) AnyConfig() ConfigRead {
295 return mergeConfig(repo.LocalConfig(), repo.GlobalConfig())
296}
297
298// Keyring give access to a user-wide storage for secrets
299func (repo *GoGitRepo) Keyring() Keyring {
300 return repo.keyring
301}
302
303// GetUserName returns the name the user has used to configure git
304func (repo *GoGitRepo) GetUserName() (string, error) {
305 return repo.AnyConfig().ReadString("user.name")
306}
307
308// GetUserEmail returns the email address that the user has used to configure git.
309func (repo *GoGitRepo) GetUserEmail() (string, error) {
310 return repo.AnyConfig().ReadString("user.email")
311}
312
313// GetCoreEditor returns the name of the editor that the user has used to configure git.
314func (repo *GoGitRepo) GetCoreEditor() (string, error) {
315 // See https://git-scm.com/docs/git-var
316 // 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.
317
318 if val, ok := os.LookupEnv("GIT_EDITOR"); ok {
319 return val, nil
320 }
321
322 val, err := repo.AnyConfig().ReadString("core.editor")
323 if err == nil && val != "" {
324 return val, nil
325 }
326 if err != nil && !errors.Is(err, ErrNoConfigEntry) {
327 return "", err
328 }
329
330 if val, ok := os.LookupEnv("VISUAL"); ok {
331 return val, nil
332 }
333
334 if val, ok := os.LookupEnv("EDITOR"); ok {
335 return val, nil
336 }
337
338 priorities := []string{
339 "editor",
340 "nano",
341 "vim",
342 "vi",
343 "emacs",
344 }
345
346 for _, cmd := range priorities {
347 if _, err = execabs.LookPath(cmd); err == nil {
348 return cmd, nil
349 }
350
351 }
352
353 return "ed", nil
354}
355
356// GetRemotes returns the configured remotes repositories.
357func (repo *GoGitRepo) GetRemotes() (map[string]string, error) {
358 cfg, err := repo.r.Config()
359 if err != nil {
360 return nil, err
361 }
362
363 result := make(map[string]string, len(cfg.Remotes))
364 for name, remote := range cfg.Remotes {
365 if len(remote.URLs) > 0 {
366 result[name] = remote.URLs[0]
367 }
368 }
369
370 return result, nil
371}
372
373// LocalStorage returns a billy.Filesystem giving access to
374// $RepoPath/.git/$Namespace.
375func (repo *GoGitRepo) LocalStorage() LocalStorage {
376 return repo.localStorage
377}
378
379func (repo *GoGitRepo) GetIndex(name string) (Index, error) {
380 repo.indexesMutex.Lock()
381 defer repo.indexesMutex.Unlock()
382
383 if index, ok := repo.indexes[name]; ok {
384 return index, nil
385 }
386
387 path := filepath.Join(repo.localStorage.Root(), indexPath, name)
388
389 index, err := openBleveIndex(path)
390 if err == nil {
391 repo.indexes[name] = index
392 }
393 return index, err
394}
395
396// FetchRefs fetch git refs matching a directory prefix to a remote
397// Ex: prefix="foo" will fetch any remote refs matching "refs/foo/*" locally.
398// The equivalent git refspec would be "refs/foo/*:refs/remotes/<remote>/foo/*"
399func (repo *GoGitRepo) FetchRefs(remote string, prefixes ...string) (string, error) {
400 refSpecs := make([]config.RefSpec, len(prefixes))
401
402 for i, prefix := range prefixes {
403 refSpecs[i] = config.RefSpec(fmt.Sprintf("refs/%s/*:refs/remotes/%s/%s/*", prefix, remote, prefix))
404 }
405
406 buf := bytes.NewBuffer(nil)
407
408 remoteUrl, err := repo.resolveRemote(remote, true)
409 if err != nil {
410 return "", err
411 }
412
413 err = repo.r.Fetch(&gogit.FetchOptions{
414 RemoteName: remote,
415 RemoteURL: remoteUrl,
416 RefSpecs: refSpecs,
417 Progress: buf,
418 })
419 if err == gogit.NoErrAlreadyUpToDate {
420 return "already up-to-date", nil
421 }
422 if err != nil {
423 return "", err
424 }
425
426 return buf.String(), nil
427}
428
429// resolveRemote returns the URI for a given remote
430func (repo *GoGitRepo) resolveRemote(remote string, fetch bool) (string, error) {
431 cfg, err := repo.r.ConfigScoped(config.SystemScope)
432 if err != nil {
433 return "", fmt.Errorf("unable to load system-scoped git config: %v", err)
434 }
435
436 var url string
437 for _, re := range cfg.Remotes {
438 if remote == re.Name {
439 // url is set matching the default logic in go-git's repository.Push
440 // and repository.Fetch logic as of go-git v5.12.1.
441 //
442 // we do this because the push and fetch methods can only take one
443 // remote for both option structs, even though the push method
444 // _should_ push to all of the URLs defined for a given remote.
445 url = re.URLs[len(re.URLs)-1]
446 if fetch {
447 url = re.URLs[0]
448 }
449
450 for _, u := range cfg.URLs {
451 if strings.HasPrefix(url, u.InsteadOf) {
452 url = u.ApplyInsteadOf(url)
453 break
454 }
455 }
456 }
457 }
458
459 if url == "" {
460 return "", fmt.Errorf("unable to resolve URL for remote: %v", err)
461 }
462
463 return url, nil
464}
465
466// PushRefs push git refs matching a directory prefix to a remote
467// Ex: prefix="foo" will push any local refs matching "refs/foo/*" to the remote.
468// The equivalent git refspec would be "refs/foo/*:refs/foo/*"
469//
470// Additionally, PushRefs will update the local references in refs/remotes/<remote>/foo to match
471// the remote state.
472func (repo *GoGitRepo) PushRefs(remote string, prefixes ...string) (string, error) {
473 remo, err := repo.r.Remote(remote)
474 if err != nil {
475 return "", err
476 }
477
478 refSpecs := make([]config.RefSpec, len(prefixes))
479
480 for i, prefix := range prefixes {
481 refspec := fmt.Sprintf("refs/%s/*:refs/%s/*", prefix, prefix)
482
483 // to make sure that the push also create the corresponding refs/remotes/<remote>/... references,
484 // we need to have a default fetch refspec configured on the remote, to make our refs "track" the remote ones.
485 // This does not change the config on disk, only on memory.
486 hasCustomFetch := false
487 fetchRefspec := fmt.Sprintf("refs/%s/*:refs/remotes/%s/%s/*", prefix, remote, prefix)
488 for _, r := range remo.Config().Fetch {
489 if string(r) == fetchRefspec {
490 hasCustomFetch = true
491 break
492 }
493 }
494
495 if !hasCustomFetch {
496 remo.Config().Fetch = append(remo.Config().Fetch, config.RefSpec(fetchRefspec))
497 }
498
499 refSpecs[i] = config.RefSpec(refspec)
500 }
501
502 buf := bytes.NewBuffer(nil)
503
504 remoteUrl, err := repo.resolveRemote(remote, false)
505 if err != nil {
506 return "", err
507 }
508
509 err = remo.Push(&gogit.PushOptions{
510 RemoteName: remote,
511 RemoteURL: remoteUrl,
512 RefSpecs: refSpecs,
513 Progress: buf,
514 })
515 if err == gogit.NoErrAlreadyUpToDate {
516 return "already up-to-date", nil
517 }
518 if err != nil {
519 return "", err
520 }
521
522 return buf.String(), nil
523}
524
525// StoreData will store arbitrary data and return the corresponding hash
526func (repo *GoGitRepo) StoreData(data []byte) (Hash, error) {
527 obj := repo.r.Storer.NewEncodedObject()
528 obj.SetType(plumbing.BlobObject)
529
530 w, err := obj.Writer()
531 if err != nil {
532 return "", err
533 }
534
535 _, err = w.Write(data)
536 if err != nil {
537 return "", err
538 }
539
540 h, err := repo.r.Storer.SetEncodedObject(obj)
541 if err != nil {
542 return "", err
543 }
544
545 return Hash(h.String()), nil
546}
547
548// ReadData will attempt to read arbitrary data from the given hash
549func (repo *GoGitRepo) ReadData(hash Hash) ([]byte, error) {
550 repo.rMutex.Lock()
551 defer repo.rMutex.Unlock()
552
553 obj, err := repo.r.BlobObject(plumbing.NewHash(hash.String()))
554 if err == plumbing.ErrObjectNotFound {
555 return nil, ErrNotFound
556 }
557 if err != nil {
558 return nil, err
559 }
560
561 r, err := obj.Reader()
562 if err != nil {
563 return nil, err
564 }
565
566 // TODO: return a io.Reader instead
567 return io.ReadAll(r)
568}
569
570// StoreTree will store a mapping key-->Hash as a Git tree
571func (repo *GoGitRepo) StoreTree(mapping []TreeEntry) (Hash, error) {
572 var tree object.Tree
573
574 // TODO: can be removed once https://github.com/go-git/go-git/issues/193 is resolved
575 sorted := make([]TreeEntry, len(mapping))
576 copy(sorted, mapping)
577 sort.Slice(sorted, func(i, j int) bool {
578 nameI := sorted[i].Name
579 if sorted[i].ObjectType == Tree {
580 nameI += "/"
581 }
582 nameJ := sorted[j].Name
583 if sorted[j].ObjectType == Tree {
584 nameJ += "/"
585 }
586 return nameI < nameJ
587 })
588
589 for _, entry := range sorted {
590 mode := filemode.Regular
591 if entry.ObjectType == Tree {
592 mode = filemode.Dir
593 }
594
595 tree.Entries = append(tree.Entries, object.TreeEntry{
596 Name: entry.Name,
597 Mode: mode,
598 Hash: plumbing.NewHash(entry.Hash.String()),
599 })
600 }
601
602 obj := repo.r.Storer.NewEncodedObject()
603 obj.SetType(plumbing.TreeObject)
604 err := tree.Encode(obj)
605 if err != nil {
606 return "", err
607 }
608
609 hash, err := repo.r.Storer.SetEncodedObject(obj)
610 if err != nil {
611 return "", err
612 }
613
614 return Hash(hash.String()), nil
615}
616
617// ReadTree will return the list of entries in a Git tree
618func (repo *GoGitRepo) ReadTree(hash Hash) ([]TreeEntry, error) {
619 repo.rMutex.Lock()
620 defer repo.rMutex.Unlock()
621
622 h := plumbing.NewHash(hash.String())
623
624 // the given hash could be a tree or a commit
625 obj, err := repo.r.Storer.EncodedObject(plumbing.AnyObject, h)
626 if err == plumbing.ErrObjectNotFound {
627 return nil, ErrNotFound
628 }
629 if err != nil {
630 return nil, err
631 }
632
633 var tree *object.Tree
634 switch obj.Type() {
635 case plumbing.TreeObject:
636 tree, err = object.DecodeTree(repo.r.Storer, obj)
637 case plumbing.CommitObject:
638 var commit *object.Commit
639 commit, err = object.DecodeCommit(repo.r.Storer, obj)
640 if err != nil {
641 return nil, err
642 }
643 tree, err = commit.Tree()
644 default:
645 return nil, fmt.Errorf("given hash is not a tree")
646 }
647 if err != nil {
648 return nil, err
649 }
650
651 treeEntries := make([]TreeEntry, len(tree.Entries))
652 for i, entry := range tree.Entries {
653 objType := Blob
654 if entry.Mode == filemode.Dir {
655 objType = Tree
656 }
657
658 treeEntries[i] = TreeEntry{
659 ObjectType: objType,
660 Hash: Hash(entry.Hash.String()),
661 Name: entry.Name,
662 }
663 }
664
665 return treeEntries, nil
666}
667
668// StoreCommit will store a Git commit with the given Git tree
669func (repo *GoGitRepo) StoreCommit(treeHash Hash, parents ...Hash) (Hash, error) {
670 return repo.StoreSignedCommit(treeHash, nil, parents...)
671}
672
673// StoreSignedCommit will store a Git commit with the given Git tree. If signKey is not nil, the commit
674// will be signed accordingly.
675func (repo *GoGitRepo) StoreSignedCommit(treeHash Hash, signKey *openpgp.Entity, parents ...Hash) (Hash, error) {
676 cfg, err := repo.r.Config()
677 if err != nil {
678 return "", err
679 }
680
681 commit := object.Commit{
682 Author: object.Signature{
683 Name: cfg.Author.Name,
684 Email: cfg.Author.Email,
685 When: time.Now(),
686 },
687 Committer: object.Signature{
688 Name: cfg.Committer.Name,
689 Email: cfg.Committer.Email,
690 When: time.Now(),
691 },
692 Message: "",
693 TreeHash: plumbing.NewHash(treeHash.String()),
694 }
695
696 for _, parent := range parents {
697 commit.ParentHashes = append(commit.ParentHashes, plumbing.NewHash(parent.String()))
698 }
699
700 // Compute the signature if needed
701 if signKey != nil {
702 // first get the serialized commit
703 encoded := &plumbing.MemoryObject{}
704 if err := commit.Encode(encoded); err != nil {
705 return "", err
706 }
707 r, err := encoded.Reader()
708 if err != nil {
709 return "", err
710 }
711
712 // sign the data
713 var sig bytes.Buffer
714 if err := openpgp.ArmoredDetachSign(&sig, signKey, r, nil); err != nil {
715 return "", err
716 }
717 commit.PGPSignature = sig.String()
718 }
719
720 obj := repo.r.Storer.NewEncodedObject()
721 obj.SetType(plumbing.CommitObject)
722 err = commit.Encode(obj)
723 if err != nil {
724 return "", err
725 }
726
727 hash, err := repo.r.Storer.SetEncodedObject(obj)
728 if err != nil {
729 return "", err
730 }
731
732 return Hash(hash.String()), nil
733}
734
735func (repo *GoGitRepo) ResolveRef(ref string) (Hash, error) {
736 r, err := repo.r.Reference(plumbing.ReferenceName(ref), false)
737 if err == plumbing.ErrReferenceNotFound {
738 return "", ErrNotFound
739 }
740 if err != nil {
741 return "", err
742 }
743 return Hash(r.Hash().String()), nil
744}
745
746// UpdateRef will create or update a Git reference
747func (repo *GoGitRepo) UpdateRef(ref string, hash Hash) error {
748 return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(ref), plumbing.NewHash(hash.String())))
749}
750
751// RemoveRef will remove a Git reference
752func (repo *GoGitRepo) RemoveRef(ref string) error {
753 return repo.r.Storer.RemoveReference(plumbing.ReferenceName(ref))
754}
755
756// ListRefs will return a list of Git ref matching the given refspec
757func (repo *GoGitRepo) ListRefs(refPrefix string) ([]string, error) {
758 refIter, err := repo.r.References()
759 if err != nil {
760 return nil, err
761 }
762
763 refs := make([]string, 0)
764
765 err = refIter.ForEach(func(ref *plumbing.Reference) error {
766 if strings.HasPrefix(ref.Name().String(), refPrefix) {
767 refs = append(refs, ref.Name().String())
768 }
769 return nil
770 })
771 if err != nil {
772 return nil, err
773 }
774
775 return refs, nil
776}
777
778// RefExist will check if a reference exist in Git
779func (repo *GoGitRepo) RefExist(ref string) (bool, error) {
780 _, err := repo.r.Reference(plumbing.ReferenceName(ref), false)
781 if err == nil {
782 return true, nil
783 } else if err == plumbing.ErrReferenceNotFound {
784 return false, nil
785 }
786 return false, err
787}
788
789// CopyRef will create a new reference with the same value as another one
790func (repo *GoGitRepo) CopyRef(source string, dest string) error {
791 r, err := repo.r.Reference(plumbing.ReferenceName(source), false)
792 if err == plumbing.ErrReferenceNotFound {
793 return ErrNotFound
794 }
795 if err != nil {
796 return err
797 }
798 return repo.r.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(dest), r.Hash()))
799}
800
801// ListCommits will return the list of tree hashes of a ref, in chronological order
802func (repo *GoGitRepo) ListCommits(ref string) ([]Hash, error) {
803 return nonNativeListCommits(repo, ref)
804}
805
806func (repo *GoGitRepo) ReadCommit(hash Hash) (Commit, error) {
807 repo.rMutex.Lock()
808 defer repo.rMutex.Unlock()
809
810 commit, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
811 if err == plumbing.ErrObjectNotFound {
812 return Commit{}, ErrNotFound
813 }
814 if err != nil {
815 return Commit{}, err
816 }
817
818 parents := make([]Hash, len(commit.ParentHashes))
819 for i, parentHash := range commit.ParentHashes {
820 parents[i] = Hash(parentHash.String())
821 }
822
823 result := Commit{
824 Hash: hash,
825 Parents: parents,
826 TreeHash: Hash(commit.TreeHash.String()),
827 }
828
829 if commit.PGPSignature != "" {
830 // I can't find a way to just remove the signature when reading the encoded commit so we need to
831 // re-encode the commit without signature.
832
833 encoded := &plumbing.MemoryObject{}
834 err := commit.EncodeWithoutSignature(encoded)
835 if err != nil {
836 return Commit{}, err
837 }
838
839 result.SignedData, err = encoded.Reader()
840 if err != nil {
841 return Commit{}, err
842 }
843
844 result.Signature, err = deArmorSignature(strings.NewReader(commit.PGPSignature))
845 if err != nil {
846 return Commit{}, err
847 }
848 }
849
850 return result, nil
851}
852
853func (repo *GoGitRepo) AllClocks() (map[string]lamport.Clock, error) {
854 repo.clocksMutex.Lock()
855 defer repo.clocksMutex.Unlock()
856
857 result := make(map[string]lamport.Clock)
858
859 files, err := os.ReadDir(filepath.Join(repo.localStorage.Root(), clockPath))
860 if os.IsNotExist(err) {
861 return nil, nil
862 }
863 if err != nil {
864 return nil, err
865 }
866
867 for _, file := range files {
868 name := file.Name()
869 if c, ok := repo.clocks[name]; ok {
870 result[name] = c
871 } else {
872 c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
873 if err != nil {
874 return nil, err
875 }
876 repo.clocks[name] = c
877 result[name] = c
878 }
879 }
880
881 return result, nil
882}
883
884// GetOrCreateClock return a Lamport clock stored in the Repo.
885// If the clock doesn't exist, it's created.
886func (repo *GoGitRepo) GetOrCreateClock(name string) (lamport.Clock, error) {
887 repo.clocksMutex.Lock()
888 defer repo.clocksMutex.Unlock()
889
890 c, err := repo.getClock(name)
891 if err == nil {
892 return c, nil
893 }
894 if err != ErrClockNotExist {
895 return nil, err
896 }
897
898 c, err = lamport.NewPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
899 if err != nil {
900 return nil, err
901 }
902
903 repo.clocks[name] = c
904 return c, nil
905}
906
907func (repo *GoGitRepo) getClock(name string) (lamport.Clock, error) {
908 if c, ok := repo.clocks[name]; ok {
909 return c, nil
910 }
911
912 c, err := lamport.LoadPersistedClock(repo.LocalStorage(), filepath.Join(clockPath, name))
913 if err == nil {
914 repo.clocks[name] = c
915 return c, nil
916 }
917 if err == lamport.ErrClockNotExist {
918 return nil, ErrClockNotExist
919 }
920 return nil, err
921}
922
923// Increment is equivalent to c = GetOrCreateClock(name) + c.Increment()
924func (repo *GoGitRepo) Increment(name string) (lamport.Time, error) {
925 c, err := repo.GetOrCreateClock(name)
926 if err != nil {
927 return lamport.Time(0), err
928 }
929 return c.Increment()
930}
931
932// Witness is equivalent to c = GetOrCreateClock(name) + c.Witness(time)
933func (repo *GoGitRepo) Witness(name string, time lamport.Time) error {
934 c, err := repo.GetOrCreateClock(name)
935 if err != nil {
936 return err
937 }
938 return c.Witness(time)
939}
940
941// commitToMeta converts a go-git Commit to a CommitMeta.
942func commitToMeta(c *object.Commit) CommitMeta {
943 h := Hash(c.Hash.String())
944 parents := make([]Hash, len(c.ParentHashes))
945 for i, p := range c.ParentHashes {
946 parents[i] = Hash(p.String())
947 }
948 // Use first line of message as the short message.
949 msg := strings.TrimSpace(c.Message)
950 if idx := strings.Index(msg, "\n"); idx >= 0 {
951 msg = msg[:idx]
952 }
953 return CommitMeta{
954 Hash: h,
955 Message: msg,
956 AuthorName: c.Author.Name,
957 AuthorEmail: c.Author.Email,
958 Date: c.Author.When,
959 Parents: parents,
960 }
961}
962
963// peelToCommit follows tag objects until it reaches a commit hash.
964// This is necessary for annotated tags, whose ref hash points to a tag object
965// rather than directly to a commit.
966func (repo *GoGitRepo) peelToCommit(h plumbing.Hash) (plumbing.Hash, error) {
967 for {
968 if _, err := repo.r.CommitObject(h); err == nil {
969 return h, nil
970 }
971 tagObj, err := repo.r.TagObject(h)
972 if err != nil {
973 return plumbing.ZeroHash, ErrNotFound
974 }
975 h = tagObj.Target
976 }
977}
978
979// resolveRefToHash resolves a branch/tag name or raw hash to a commit hash.
980// Resolution order: refs/heads/<ref>, refs/tags/<ref>, full ref name, raw commit hash.
981// Annotated tags are peeled to their target commit.
982func (repo *GoGitRepo) resolveRefToHash(ref string) (plumbing.Hash, error) {
983 for _, prefix := range []string{"refs/heads/", "refs/tags/"} {
984 r, err := repo.r.Reference(plumbing.ReferenceName(prefix+ref), true)
985 if err == nil {
986 return repo.peelToCommit(r.Hash())
987 }
988 }
989 // try as a full ref name
990 r, err := repo.r.Reference(plumbing.ReferenceName(ref), true)
991 if err == nil {
992 return repo.peelToCommit(r.Hash())
993 }
994 // try as a raw commit hash
995 h := plumbing.NewHash(ref)
996 if h != plumbing.ZeroHash {
997 if _, err := repo.r.CommitObject(h); err == nil {
998 return h, nil
999 }
1000 }
1001 return plumbing.ZeroHash, ErrNotFound
1002}
1003
1004// defaultBranchName returns the short name of the default branch.
1005func (repo *GoGitRepo) defaultBranchName() string {
1006 repo.rMutex.Lock()
1007 defer repo.rMutex.Unlock()
1008
1009 // refs/remotes/origin/HEAD is a symbolic ref set by git clone that points
1010 // to the remote's default branch (e.g. refs/remotes/origin/main). It is
1011 // the most reliable signal for "what does the upstream consider default".
1012 ref, err := repo.r.Reference("refs/remotes/origin/HEAD", false)
1013 if err == nil && ref.Type() == plumbing.SymbolicReference {
1014 const prefix = "refs/remotes/origin/"
1015 if target := ref.Target().String(); strings.HasPrefix(target, prefix) {
1016 return strings.TrimPrefix(target, prefix)
1017 }
1018 }
1019 // Fall back to well-known names for repos without a configured remote.
1020 for _, name := range []string{"main", "master", "trunk", "develop"} {
1021 _, err := repo.r.Reference(plumbing.NewBranchReferenceName(name), false)
1022 if err == nil {
1023 return name
1024 }
1025 }
1026 return ""
1027}
1028
1029// Branches returns all local branches. IsDefault marks the upstream's default
1030// branch, determined in order:
1031// 1. refs/remotes/origin/HEAD (set by git clone, reflects the server default)
1032// 2. First match among: main, master, trunk, develop
1033// 3. No branch marked if none of the above resolve
1034func (repo *GoGitRepo) Branches() ([]BranchInfo, error) {
1035 defaultBranch := repo.defaultBranchName()
1036
1037 repo.rMutex.Lock()
1038 defer repo.rMutex.Unlock()
1039
1040 refs, err := repo.r.References()
1041 if err != nil {
1042 return nil, err
1043 }
1044
1045 var branches []BranchInfo
1046 err = refs.ForEach(func(r *plumbing.Reference) error {
1047 if !r.Name().IsBranch() {
1048 return nil
1049 }
1050 branches = append(branches, BranchInfo{
1051 Name: r.Name().Short(),
1052 Hash: Hash(r.Hash().String()),
1053 IsDefault: r.Name().Short() == defaultBranch,
1054 })
1055 return nil
1056 })
1057 if err != nil {
1058 return nil, err
1059 }
1060 if branches == nil {
1061 branches = []BranchInfo{}
1062 }
1063 return branches, nil
1064}
1065
1066// Tags returns all tags. For annotated tags the hash is dereferenced to the
1067// target commit; for lightweight tags it is the commit hash directly.
1068func (repo *GoGitRepo) Tags() ([]TagInfo, error) {
1069 repo.rMutex.Lock()
1070 defer repo.rMutex.Unlock()
1071
1072 refs, err := repo.r.References()
1073 if err != nil {
1074 return nil, err
1075 }
1076
1077 var tags []TagInfo
1078 err = refs.ForEach(func(r *plumbing.Reference) error {
1079 if !r.Name().IsTag() {
1080 return nil
1081 }
1082 // Peel to the target commit hash, handling arbitrarily nested tag objects.
1083 commit, err := repo.peelToCommit(r.Hash())
1084 if err != nil {
1085 // Skip refs that don't resolve to a commit (shouldn't happen for tags).
1086 return nil
1087 }
1088 tags = append(tags, TagInfo{
1089 Name: r.Name().Short(),
1090 Hash: Hash(commit.String()),
1091 })
1092 return nil
1093 })
1094 if err != nil {
1095 return nil, err
1096 }
1097 if tags == nil {
1098 tags = []TagInfo{}
1099 }
1100 return tags, nil
1101}
1102
1103// TreeAtPath returns the entries of the directory at path under ref.
1104func (repo *GoGitRepo) TreeAtPath(ref, path string) ([]TreeEntry, error) {
1105 path = strings.Trim(path, "/")
1106
1107 repo.rMutex.Lock()
1108 defer repo.rMutex.Unlock()
1109
1110 startHash, err := repo.resolveRefToHash(ref)
1111 if err != nil {
1112 return nil, ErrNotFound
1113 }
1114 commit, err := repo.r.CommitObject(startHash)
1115 if err != nil {
1116 return nil, err
1117 }
1118 tree, err := commit.Tree()
1119 if err != nil {
1120 return nil, err
1121 }
1122 if path != "" {
1123 subtree, err := tree.Tree(path)
1124 if err != nil {
1125 return nil, ErrNotFound
1126 }
1127 tree = subtree
1128 }
1129
1130 entries := make([]TreeEntry, len(tree.Entries))
1131 for i, e := range tree.Entries {
1132 entries[i] = TreeEntry{
1133 Name: e.Name,
1134 Hash: Hash(e.Hash.String()),
1135 ObjectType: objectTypeFromFileMode(e.Mode),
1136 }
1137 }
1138 return entries, nil
1139}
1140
1141// objectTypeFromFileMode maps a go-git filemode to the repository ObjectType.
1142func objectTypeFromFileMode(m filemode.FileMode) ObjectType {
1143 switch m {
1144 case filemode.Dir:
1145 return Tree
1146 case filemode.Regular:
1147 return Blob
1148 case filemode.Executable:
1149 return Executable
1150 case filemode.Symlink:
1151 return Symlink
1152 case filemode.Submodule:
1153 return Submodule
1154 default:
1155 return Unknown
1156 }
1157}
1158
1159// BlobAtPath returns the content, size, and git object hash of the file at
1160// path under ref. rMutex is held for the entire function, covering all
1161// shared-Scanner access (CommitObject, Tree, File). The returned reader is
1162// safe to use without the mutex: small blobs are already materialized into a
1163// MemoryObject (bytes.Reader) by the time File() returns; large blobs come
1164// back as an FSObject whose Reader() opens its own independent file handle and
1165// Scanner and then reads via ReadAt — no shared state is touched after this
1166// function returns. Callers must Close the reader.
1167func (repo *GoGitRepo) BlobAtPath(ref, path string) (io.ReadCloser, int64, Hash, error) {
1168 path = strings.Trim(path, "/")
1169 if path == "" {
1170 return nil, 0, "", ErrNotFound
1171 }
1172
1173 repo.rMutex.Lock()
1174 defer repo.rMutex.Unlock()
1175
1176 startHash, err := repo.resolveRefToHash(ref)
1177 if err != nil {
1178 return nil, 0, "", ErrNotFound
1179 }
1180 commit, err := repo.r.CommitObject(startHash)
1181 if err != nil {
1182 return nil, 0, "", err
1183 }
1184 tree, err := commit.Tree()
1185 if err != nil {
1186 return nil, 0, "", err
1187 }
1188 f, err := tree.File(path)
1189 if err != nil {
1190 return nil, 0, "", ErrNotFound
1191 }
1192 r, err := f.Reader()
1193 if err != nil {
1194 return nil, 0, "", err
1195 }
1196
1197 return r, f.Blob.Size, Hash(f.Blob.Hash.String()), nil
1198}
1199
1200// CommitLog returns at most limit commits reachable from ref, optionally
1201// filtered to those that touched path, starting after the given cursor hash,
1202// and bounded by the since/until author-date range.
1203func (repo *GoGitRepo) CommitLog(ref, path string, limit int, after Hash, since, until *time.Time) ([]CommitMeta, error) {
1204 repo.rMutex.Lock()
1205 defer repo.rMutex.Unlock()
1206
1207 startHash, err := repo.resolveRefToHash(ref)
1208 if err != nil {
1209 return nil, err
1210 }
1211
1212 // Normalize path: strip leading/trailing slashes so prefix matching works.
1213 path = strings.Trim(path, "/")
1214
1215 opts := &gogit.LogOptions{
1216 From: startHash,
1217 Order: gogit.LogOrderCommitterTime,
1218 }
1219 if path != "" {
1220 opts.PathFilter = func(p string) bool {
1221 return p == path || strings.HasPrefix(p, path+"/")
1222 }
1223 }
1224
1225 iter, err := repo.r.Log(opts)
1226 if err != nil {
1227 return nil, err
1228 }
1229 defer iter.Close()
1230
1231 var result []CommitMeta
1232 skipping := after != ""
1233 for {
1234 c, err := iter.Next()
1235 if err == io.EOF {
1236 break
1237 }
1238 if err != nil {
1239 return nil, err
1240 }
1241 h := Hash(c.Hash.String())
1242 if skipping {
1243 if h == after {
1244 skipping = false
1245 }
1246 continue
1247 }
1248 if since != nil && c.Author.When.Before(*since) {
1249 continue
1250 }
1251 if until != nil && c.Author.When.After(*until) {
1252 continue
1253 }
1254 result = append(result, commitToMeta(c))
1255 if limit > 0 && len(result) >= limit {
1256 break
1257 }
1258 }
1259 return result, nil
1260}
1261
1262// treeEntriesAtPath returns the tree hash and a name→entry-hash map for the
1263// directory at dirPath inside the given commit. An empty dirPath means the
1264// root tree. The tree hash is content-addressed and can be used as a stable
1265// cache key regardless of which branch or ref was resolved.
1266func treeEntriesAtPath(c *object.Commit, dirPath string) (plumbing.Hash, map[string]plumbing.Hash, error) {
1267 tree, err := c.Tree()
1268 if err != nil {
1269 return plumbing.ZeroHash, nil, err
1270 }
1271 if dirPath != "" {
1272 subtree, err := tree.Tree(dirPath)
1273 if err != nil {
1274 return plumbing.ZeroHash, nil, err
1275 }
1276 tree = subtree
1277 }
1278 result := make(map[string]plumbing.Hash, len(tree.Entries))
1279 for _, e := range tree.Entries {
1280 result[e.Name] = e.Hash
1281 }
1282 return tree.Hash, result, nil
1283}
1284
1285// LastCommitForEntries performs a single history walk to find, for each name,
1286// the most recent commit that changed that entry in the directory at path.
1287//
1288// Results are cached by (dirTreeHash, path). Because git trees are
1289// content-addressed, two refs that point to the same directory tree share one
1290// cache entry, and the cache never needs invalidation: a changed directory
1291// produces a new tree hash, which becomes a new key.
1292func (repo *GoGitRepo) LastCommitForEntries(ref, path string, names []string) (map[string]CommitMeta, error) {
1293 // Normalize path up front so the cache key is canonical.
1294 path = strings.Trim(path, "/")
1295
1296 // Resolve ref and load the current directory tree in one brief lock.
1297 // We need the tree hash for the cache key and we keep the entries to
1298 // seed the parent-reuse optimisation in the walk below.
1299 repo.rMutex.Lock()
1300 startHash, err := repo.resolveRefToHash(ref)
1301 if err != nil {
1302 repo.rMutex.Unlock()
1303 return nil, err
1304 }
1305 startCommit, err := repo.r.CommitObject(startHash)
1306 if err != nil {
1307 repo.rMutex.Unlock()
1308 return nil, err
1309 }
1310 treeHash, startEntries, err := treeEntriesAtPath(startCommit, path)
1311 repo.rMutex.Unlock()
1312 if err != nil {
1313 // path doesn't exist at HEAD — nothing to return.
1314 return map[string]CommitMeta{}, nil
1315 }
1316
1317 // The cache is keyed by the directory's tree hash (content-addressed)
1318 // plus the path so two directories with identical content but different
1319 // locations don't collide.
1320 cacheKey := treeHash.String() + "\x00" + path
1321
1322 // Cache hit: filter the stored result down to the requested names.
1323 if cached, ok := repo.lastCommitCache.Get(cacheKey); ok {
1324 result := make(map[string]CommitMeta, len(names))
1325 for _, n := range names {
1326 if m, found := cached[n]; found {
1327 result[n] = m
1328 }
1329 }
1330 return result, nil
1331 }
1332
1333 // Cache miss: walk history for ALL entries in this directory so the
1334 // cached result is complete and valid for any future name subset.
1335 remaining := make(map[string]bool, len(startEntries))
1336 for name := range startEntries {
1337 remaining[name] = true
1338 }
1339 result := make(map[string]CommitMeta, len(remaining))
1340
1341 repo.rMutex.Lock()
1342
1343 iter, err := repo.r.Log(&gogit.LogOptions{
1344 From: startHash,
1345 Order: gogit.LogOrderCommitterTime,
1346 })
1347 if err != nil {
1348 repo.rMutex.Unlock()
1349 return nil, err
1350 }
1351
1352 // Seed the parent-reuse cache with the entries we already fetched above
1353 // so the first iteration's current-tree read is skipped for free.
1354 // In a linear history this halves tree reads for every subsequent step:
1355 // the parent fetched at depth D is the current commit at depth D+1.
1356 cachedParentHash := startHash
1357 cachedParentEntries := startEntries
1358
1359 for depth := 0; len(remaining) > 0 && depth < lastCommitDepthLimit; depth++ {
1360 c, err := iter.Next()
1361 if err == io.EOF {
1362 break
1363 }
1364 if err != nil {
1365 iter.Close()
1366 repo.rMutex.Unlock()
1367 return nil, err
1368 }
1369
1370 var currentEntries map[string]plumbing.Hash
1371 if c.Hash == cachedParentHash && cachedParentEntries != nil {
1372 currentEntries = cachedParentEntries
1373 } else {
1374 _, currentEntries, err = treeEntriesAtPath(c, path)
1375 if err != nil {
1376 // path may not exist in this commit; treat as empty
1377 currentEntries = map[string]plumbing.Hash{}
1378 }
1379 }
1380
1381 var parentEntries map[string]plumbing.Hash
1382 cachedParentHash = plumbing.ZeroHash
1383 cachedParentEntries = nil
1384 if len(c.ParentHashes) > 0 {
1385 if parent, err := c.Parents().Next(); err == nil {
1386 _, parentEntries, _ = treeEntriesAtPath(parent, path)
1387 cachedParentHash = c.ParentHashes[0]
1388 cachedParentEntries = parentEntries
1389 }
1390 }
1391
1392 meta := commitToMeta(c)
1393 for name := range remaining {
1394 curHash, inCurrent := currentEntries[name]
1395 parentHash, inParent := parentEntries[name]
1396 if inCurrent != inParent || (inCurrent && curHash != parentHash) {
1397 result[name] = meta
1398 delete(remaining, name)
1399 }
1400 }
1401 }
1402
1403 iter.Close()
1404 repo.rMutex.Unlock()
1405
1406 // Store a defensive copy so that callers cannot mutate cached entries.
1407 // The cached map contains all directory entries, not just the requested
1408 // names, so future calls for the same directory are fully served from
1409 // cache regardless of which names they request.
1410 cached := make(map[string]CommitMeta, len(result))
1411 for k, v := range result {
1412 cached[k] = v
1413 }
1414 repo.lastCommitCache.Add(cacheKey, cached)
1415
1416 // Return only the entries that were requested.
1417 filtered := make(map[string]CommitMeta, len(names))
1418 for _, n := range names {
1419 if m, ok := result[n]; ok {
1420 filtered[n] = m
1421 }
1422 }
1423 return filtered, nil
1424}
1425
1426// CommitDetail returns the full commit metadata and list of changed files.
1427func (repo *GoGitRepo) CommitDetail(hash Hash) (CommitDetail, error) {
1428 repo.rMutex.Lock()
1429 defer repo.rMutex.Unlock()
1430
1431 c, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
1432 if err == plumbing.ErrObjectNotFound {
1433 return CommitDetail{}, ErrNotFound
1434 }
1435 if err != nil {
1436 return CommitDetail{}, err
1437 }
1438
1439 toTree, err := c.Tree()
1440 if err != nil {
1441 return CommitDetail{}, err
1442 }
1443
1444 var fromTree *object.Tree
1445 if len(c.ParentHashes) > 0 {
1446 parent, err := repo.r.CommitObject(c.ParentHashes[0])
1447 if err != nil {
1448 return CommitDetail{}, fmt.Errorf("loading parent commit: %w", err)
1449 }
1450 fromTree, err = parent.Tree()
1451 if err != nil {
1452 return CommitDetail{}, fmt.Errorf("loading parent tree: %w", err)
1453 }
1454 }
1455
1456 changes, err := object.DiffTree(fromTree, toTree)
1457 if err != nil {
1458 return CommitDetail{}, err
1459 }
1460
1461 // Use ch.From.Name / ch.To.Name directly — these come from the tree
1462 // metadata and do not require reading any blob content.
1463 files := make([]ChangedFile, 0, len(changes))
1464 for _, ch := range changes {
1465 files = append(files, changedFileFromChange(ch.From.Name, ch.To.Name))
1466 }
1467
1468 return CommitDetail{
1469 CommitMeta: commitToMeta(c),
1470 FullMessage: c.Message,
1471 Files: files,
1472 }, nil
1473}
1474
1475func changedFileFromChange(fromName, toName string) ChangedFile {
1476 switch {
1477 case fromName == "":
1478 return ChangedFile{Path: toName, Status: ChangeStatusAdded}
1479 case toName == "":
1480 return ChangedFile{Path: fromName, Status: ChangeStatusDeleted}
1481 case fromName != toName:
1482 op := fromName
1483 return ChangedFile{Path: toName, OldPath: &op, Status: ChangeStatusRenamed}
1484 default:
1485 return ChangedFile{Path: toName, Status: ChangeStatusModified}
1486 }
1487}
1488
1489// CommitFileDiff returns the unified diff for a single file in a commit,
1490// relative to the first parent.
1491func (repo *GoGitRepo) CommitFileDiff(hash Hash, filePath string) (FileDiff, error) {
1492 repo.rMutex.Lock()
1493 defer repo.rMutex.Unlock()
1494
1495 c, err := repo.r.CommitObject(plumbing.NewHash(hash.String()))
1496 if err == plumbing.ErrObjectNotFound {
1497 return FileDiff{}, ErrNotFound
1498 }
1499 if err != nil {
1500 return FileDiff{}, err
1501 }
1502
1503 toTree, err := c.Tree()
1504 if err != nil {
1505 return FileDiff{}, err
1506 }
1507
1508 var fromTree *object.Tree
1509 if len(c.ParentHashes) > 0 {
1510 parent, err := repo.r.CommitObject(c.ParentHashes[0])
1511 if err != nil {
1512 return FileDiff{}, fmt.Errorf("loading parent commit: %w", err)
1513 }
1514 fromTree, err = parent.Tree()
1515 if err != nil {
1516 return FileDiff{}, fmt.Errorf("loading parent tree: %w", err)
1517 }
1518 }
1519
1520 changes, err := object.DiffTree(fromTree, toTree)
1521 if err != nil {
1522 return FileDiff{}, err
1523 }
1524
1525 for _, ch := range changes {
1526 name := ch.To.Name
1527 if name == "" {
1528 name = ch.From.Name
1529 }
1530 // match on either new or old path
1531 if name != filePath && ch.From.Name != filePath {
1532 continue
1533 }
1534
1535 from, to, err := ch.Files()
1536 if err != nil {
1537 return FileDiff{}, err
1538 }
1539
1540 patch, err := ch.Patch()
1541 if err != nil {
1542 return FileDiff{}, err
1543 }
1544
1545 fd := FileDiff{
1546 IsNew: from == nil,
1547 IsDelete: to == nil,
1548 }
1549 if to != nil {
1550 fd.Path = to.Name
1551 }
1552 if from != nil {
1553 if fd.Path == "" {
1554 fd.Path = from.Name
1555 } else if from.Name != fd.Path {
1556 op := from.Name
1557 fd.OldPath = &op
1558 }
1559 }
1560
1561 fps := patch.FilePatches()
1562 if len(fps) > 0 {
1563 fp := fps[0]
1564 fd.IsBinary = fp.IsBinary()
1565 if !fd.IsBinary {
1566 fd.Hunks = buildDiffHunks(fp)
1567 }
1568 }
1569 return fd, nil
1570 }
1571 return FileDiff{}, ErrNotFound
1572}
1573
1574// buildDiffHunks converts a go-git FilePatch into DiffHunks with line numbers
1575// and context grouping.
1576func buildDiffHunks(fp fdiff.FilePatch) []DiffHunk {
1577 type pendingLine struct {
1578 typ DiffLineType
1579 content string
1580 oldLine int
1581 newLine int
1582 }
1583
1584 var allLines []pendingLine
1585 oldLine, newLine := 1, 1
1586 for _, chunk := range fp.Chunks() {
1587 lines := strings.Split(chunk.Content(), "\n")
1588 // strip trailing empty element produced by a trailing newline
1589 if len(lines) > 0 && lines[len(lines)-1] == "" {
1590 lines = lines[:len(lines)-1]
1591 }
1592 switch chunk.Type() {
1593 case fdiff.Equal:
1594 for _, l := range lines {
1595 allLines = append(allLines, pendingLine{DiffLineContext, l, oldLine, newLine})
1596 oldLine++
1597 newLine++
1598 }
1599 case fdiff.Add:
1600 for _, l := range lines {
1601 allLines = append(allLines, pendingLine{DiffLineAdded, l, 0, newLine})
1602 newLine++
1603 }
1604 case fdiff.Delete:
1605 for _, l := range lines {
1606 allLines = append(allLines, pendingLine{DiffLineDeleted, l, oldLine, 0})
1607 oldLine++
1608 }
1609 }
1610 }
1611 if len(allLines) == 0 {
1612 return nil
1613 }
1614
1615 const ctx = 3 // context lines around each changed block
1616
1617 // find spans of changed lines
1618 type span struct{ start, end int }
1619 var spans []span
1620 for i, l := range allLines {
1621 if l.typ == DiffLineContext {
1622 continue
1623 }
1624 if len(spans) == 0 || i > spans[len(spans)-1].end+1 {
1625 spans = append(spans, span{i, i})
1626 } else {
1627 spans[len(spans)-1].end = i
1628 }
1629 }
1630
1631 // expand each span by ctx lines and merge overlapping ones
1632 var merged []span
1633 for _, s := range spans {
1634 s.start = max(0, s.start-ctx)
1635 s.end = min(len(allLines)-1, s.end+ctx)
1636 if len(merged) > 0 && s.start <= merged[len(merged)-1].end+1 {
1637 merged[len(merged)-1].end = s.end
1638 } else {
1639 merged = append(merged, s)
1640 }
1641 }
1642
1643 hunks := make([]DiffHunk, 0, len(merged))
1644 for _, s := range merged {
1645 segment := allLines[s.start : s.end+1]
1646 dl := make([]DiffLine, len(segment))
1647 var oldStart, newStart, oldCount, newCount int
1648 for i, l := range segment {
1649 dl[i] = DiffLine{Type: l.typ, Content: l.content, OldLine: l.oldLine, NewLine: l.newLine}
1650 if l.oldLine > 0 {
1651 if oldStart == 0 {
1652 oldStart = l.oldLine
1653 }
1654 oldCount++
1655 }
1656 if l.newLine > 0 {
1657 if newStart == 0 {
1658 newStart = l.newLine
1659 }
1660 newCount++
1661 }
1662 }
1663 hunks = append(hunks, DiffHunk{
1664 OldStart: oldStart,
1665 OldLines: oldCount,
1666 NewStart: newStart,
1667 NewLines: newCount,
1668 Lines: dl,
1669 })
1670 }
1671 return hunks
1672}
1673
1674// AddRemote add a new remote to the repository
1675// Not in the interface because it's only used for testing
1676func (repo *GoGitRepo) AddRemote(name string, url string) error {
1677 _, err := repo.r.CreateRemote(&config.RemoteConfig{
1678 Name: name,
1679 URLs: []string{url},
1680 })
1681
1682 return err
1683}
1684
1685// GetLocalRemote return the URL to use to add this repo as a local remote
1686func (repo *GoGitRepo) GetLocalRemote() string {
1687 return repo.path
1688}
1689
1690// EraseFromDisk delete this repository entirely from the disk
1691func (repo *GoGitRepo) EraseFromDisk() error {
1692 err := repo.Close()
1693 if err != nil {
1694 return err
1695 }
1696
1697 path := filepath.Clean(strings.TrimSuffix(repo.path, string(filepath.Separator)+".git"))
1698
1699 // fmt.Println("Cleaning repo:", path)
1700 return os.RemoveAll(path)
1701}