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