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	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}