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