gogit.go

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