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