Files
CLIProxyAPI/internal/store/gitstore.go
hkfires b908ed5d86 fix(store): close git repos during recovery
Close go-git repository handles across store operations and tests to avoid
leaked handles blocking recovery cleanup. Abort recovery before mutating the
worktree when close failures occur and retain backups when rollback fails.
2026-08-31 08:41:08 +08:00

2046 lines
67 KiB
Go

package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/config"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/client"
gitindex "github.com/go-git/go-git/v6/plumbing/format/index"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/go-git/go-git/v6/plumbing/transport"
"github.com/go-git/go-git/v6/plumbing/transport/http"
"github.com/go-git/go-git/v6/storage/filesystem/dotgit"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
const (
// gcInterval defines minimum time between garbage collection runs.
gcInterval = 5 * time.Minute
// gcPruneGracePeriod keeps recently orphaned objects available for recovery.
gcPruneGracePeriod = 24 * time.Hour
)
// GitTokenStore persists token records and auth metadata using git as the backing storage.
type GitTokenStore struct {
mu sync.Mutex
dirLock sync.RWMutex
baseDir string
repoDir string
configDir string
remote string
branch string
username string
password string
lastGC time.Time
}
type resolvedRemoteBranch struct {
name plumbing.ReferenceName
hash plumbing.Hash
}
// NewGitTokenStore creates a token store that saves credentials to disk through the
// TokenStorage implementation embedded in the token record.
// When branch is non-empty, clone/pull/push operations target that branch instead of the remote default.
func NewGitTokenStore(remote, username, password, branch string) *GitTokenStore {
return &GitTokenStore{
remote: remote,
branch: strings.TrimSpace(branch),
username: username,
password: password,
}
}
// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.
func (s *GitTokenStore) SetBaseDir(dir string) {
s.mu.Lock()
defer s.mu.Unlock()
clean := strings.TrimSpace(dir)
if clean == "" {
s.dirLock.Lock()
s.baseDir = ""
s.repoDir = ""
s.configDir = ""
s.dirLock.Unlock()
return
}
if abs, err := filepath.Abs(clean); err == nil {
clean = abs
}
repoDir := filepath.Dir(clean)
if repoDir == "" || repoDir == "." {
repoDir = clean
}
configDir := filepath.Join(repoDir, "config")
s.dirLock.Lock()
s.baseDir = clean
s.repoDir = repoDir
s.configDir = configDir
s.dirLock.Unlock()
}
// AuthDir returns the directory used for auth persistence.
func (s *GitTokenStore) AuthDir() string {
return s.baseDirSnapshot()
}
// ConfigPath returns the managed config file path.
func (s *GitTokenStore) ConfigPath() string {
s.dirLock.RLock()
defer s.dirLock.RUnlock()
if s.configDir == "" {
return ""
}
return filepath.Join(s.configDir, "config.yaml")
}
// EnsureRepository prepares the local git working tree by cloning or opening the repository.
func (s *GitTokenStore) EnsureRepository() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.ensureRepositoryLocked()
}
func (s *GitTokenStore) ensureRepositoryLocked() (errResult error) {
s.dirLock.Lock()
if s.remote == "" {
s.dirLock.Unlock()
return fmt.Errorf("git token store: remote not configured")
}
if s.baseDir == "" {
s.dirLock.Unlock()
return fmt.Errorf("git token store: base directory not configured")
}
repoDir := s.repoDir
if repoDir == "" {
repoDir = filepath.Dir(s.baseDir)
if repoDir == "" || repoDir == "." {
repoDir = s.baseDir
}
s.repoDir = repoDir
}
if s.configDir == "" {
s.configDir = filepath.Join(repoDir, "config")
}
authDir := filepath.Join(repoDir, "auths")
configDir := filepath.Join(repoDir, "config")
gitDir := filepath.Join(repoDir, ".git")
authMethod := s.gitClientOptions()
var initPaths []string
if _, err := os.Stat(gitDir); errors.Is(err, fs.ErrNotExist) {
if errMk := os.MkdirAll(repoDir, 0o700); errMk != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: create repo dir: %w", errMk)
}
cloneOpts := &git.CloneOptions{ClientOptions: authMethod, URL: s.remote}
if s.branch != "" {
cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch)
}
if cloned, errClone := git.PlainClone(repoDir, cloneOpts); errClone != nil {
if errors.Is(errClone, transport.ErrEmptyRemoteRepository) {
_ = os.RemoveAll(gitDir)
repo, errInit := git.PlainInit(repoDir, false)
if errInit != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: init empty repo: %w", errInit)
}
defer func() {
if errClose := repo.Close(); errClose != nil {
errCloseWrap := fmt.Errorf("git token store: close initialized empty repo: %w", errClose)
if errResult == nil {
errResult = errCloseWrap
} else {
errResult = errors.Join(errResult, errCloseWrap)
}
}
}()
if s.branch != "" {
headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(s.branch))
if errHead := repo.Storer.SetReference(headRef); errHead != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: set head to branch %s: %w", s.branch, errHead)
}
}
if _, errRemote := repo.Remote("origin"); errRemote != nil {
if _, errCreate := repo.CreateRemote(&config.RemoteConfig{
Name: "origin",
URLs: []string{s.remote},
}); errCreate != nil && !errors.Is(errCreate, git.ErrRemoteExists) {
s.dirLock.Unlock()
return fmt.Errorf("git token store: configure remote: %w", errCreate)
}
}
if err := os.MkdirAll(authDir, 0o700); err != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: create auth dir: %w", err)
}
if err := os.MkdirAll(configDir, 0o700); err != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: create config dir: %w", err)
}
if err := ensureEmptyFile(filepath.Join(authDir, ".gitkeep")); err != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: create auth placeholder: %w", err)
}
if err := ensureEmptyFile(filepath.Join(configDir, ".gitkeep")); err != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: create config placeholder: %w", err)
}
initPaths = []string{
filepath.Join("auths", ".gitkeep"),
filepath.Join("config", ".gitkeep"),
}
} else {
s.dirLock.Unlock()
return fmt.Errorf("git token store: clone remote: %w", errClone)
}
} else if cloned != nil {
if errClose := cloned.Close(); errClose != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: close cloned repo: %w", errClose)
}
}
} else if err != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: stat repo: %w", err)
} else {
repo, errOpen := git.PlainOpen(repoDir)
if errOpen != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: open repo: %w", errOpen)
}
defer func() {
if repo != nil {
if errClose := repo.Close(); errClose != nil {
errCloseWrap := fmt.Errorf("git token store: close repo: %w", errClose)
if errResult == nil {
errResult = errCloseWrap
} else {
errResult = errors.Join(errResult, errCloseWrap)
}
}
}
}()
worktree, errWorktree := repo.Worktree()
if errWorktree != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: worktree: %w", errWorktree)
}
if errVerify := verifyRepositoryHead(repo); errVerify != nil {
if !isRepositoryCorruptionError(errVerify) {
s.dirLock.Unlock()
return fmt.Errorf("git token store: verify repository before pull: %w", errVerify)
}
errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, nil, nil, (*git.Repository).Close, os.Rename)
repo = nil
if errRecover != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: verify repository before pull: %w; recovery failed: %w", errVerify, errRecover)
}
repo, errOpen = git.PlainOpen(repoDir)
if errOpen != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: open recovered repo: %w", errOpen)
}
worktree, errWorktree = repo.Worktree()
if errWorktree != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: recovered worktree: %w", errWorktree)
}
}
if s.branch != "" {
if errCheckout := s.checkoutConfiguredBranch(repo, worktree, authMethod); errCheckout != nil {
s.dirLock.Unlock()
return errCheckout
}
} else {
// When branch is unset, ensure the working tree follows the remote default branch
if err := checkoutRemoteDefaultBranch(repo, worktree, authMethod); err != nil {
if !shouldFallbackToCurrentBranch(repo, err) {
s.dirLock.Unlock()
return fmt.Errorf("git token store: checkout remote default: %w", err)
}
}
}
pullOpts := &git.PullOptions{ClientOptions: authMethod, RemoteName: "origin"}
if s.branch != "" {
pullOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch)
}
prePullHead, errPrePullHead := repo.Head()
if errPrePullHead != nil && !errors.Is(errPrePullHead, plumbing.ErrReferenceNotFound) {
s.dirLock.Unlock()
return fmt.Errorf("git token store: get head before pull: %w", errPrePullHead)
}
var prePullTree *object.Tree
if prePullHead != nil {
prePullCommit, errPrePullCommit := repo.CommitObject(prePullHead.Hash())
if errPrePullCommit != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: inspect head before pull: %w", errPrePullCommit)
}
prePullTree, errPrePullCommit = prePullCommit.Tree()
if errPrePullCommit != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: inspect tree before pull: %w", errPrePullCommit)
}
}
dirtyPaths, errDirtyPaths := worktreeDirtyPaths(worktree)
if errDirtyPaths != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: inspect worktree before pull: %w", errDirtyPaths)
}
repositoryRecovered := false
if errPull := worktree.Pull(pullOpts); errPull != nil {
switch {
case errors.Is(errPull, git.NoErrAlreadyUpToDate):
if errReset := resetIndexToHead(repo, worktree); errReset != nil {
if !isRepositoryCorruptionError(errReset) {
s.dirLock.Unlock()
return fmt.Errorf("git token store: repair index after up-to-date pull: %w", errReset)
}
errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)
repo = nil
if errRecover != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: repair index after up-to-date pull: %w; recovery failed: %w", errReset, errRecover)
}
repositoryRecovered = true
}
case errors.Is(errPull, git.ErrUnstagedChanges), errors.Is(errPull, git.ErrNonFastForwardUpdate):
if prePullHead == nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: reconcile pull without a local branch")
}
if errReconcile := reconcileRemoteWorktree(repo, worktree, repoDir, prePullHead, dirtyPaths); errReconcile != nil {
if !isRepositoryCorruptionError(errReconcile) {
s.dirLock.Unlock()
return fmt.Errorf("git token store: reconcile remote changes: %w", errReconcile)
}
errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)
repo = nil
if errRecover != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: reconcile remote changes: %w; recovery failed: %w", errReconcile, errRecover)
}
repositoryRecovered = true
}
case errors.Is(errPull, transport.ErrAuthenticationRequired),
errors.Is(errPull, transport.ErrEmptyRemoteRepository):
// Ignore authentication prompts and empty remote references on initial sync.
case errors.Is(errPull, plumbing.ErrReferenceNotFound):
if s.branch != "" {
s.dirLock.Unlock()
return fmt.Errorf("git token store: pull: %w", errPull)
}
// Ignore missing references only when following the remote default branch.
case isRepositoryCorruptionError(errPull):
errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)
repo = nil
if errRecover != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: pull: %w; recovery failed: %w", errPull, errRecover)
}
repositoryRecovered = true
default:
s.dirLock.Unlock()
return fmt.Errorf("git token store: pull: %w", errPull)
}
}
if !repositoryRecovered {
if errVerify := verifyRepositoryHead(repo); errVerify != nil {
if !isRepositoryCorruptionError(errVerify) {
s.dirLock.Unlock()
return fmt.Errorf("git token store: verify repository after pull: %w", errVerify)
}
errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)
repo = nil
if errRecover != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: verify repository after pull: %w; recovery failed: %w", errVerify, errRecover)
}
repositoryRecovered = true
}
}
if !repositoryRecovered {
if errRestore := restoreMissingTrackedFiles(repo, repoDir); errRestore != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: restore tracked worktree files: %w", errRestore)
}
}
}
if err := disableGitCommitSigning(repoDir); err != nil {
s.dirLock.Unlock()
return err
}
if err := os.MkdirAll(s.baseDir, 0o700); err != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: create auth dir: %w", err)
}
if err := os.MkdirAll(s.configDir, 0o700); err != nil {
s.dirLock.Unlock()
return fmt.Errorf("git token store: create config dir: %w", err)
}
s.dirLock.Unlock()
if len(initPaths) > 0 {
if errCommit := s.commitAndPushInitialLocked("Initialize git token store", initPaths...); errCommit != nil {
return errCommit
}
}
return nil
}
// Save persists token storage and metadata to the resolved auth file path.
func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("auth filestore: auth is nil")
}
cliproxyauth.NormalizeCredentialMetadata(auth.Metadata)
if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil {
return "", fmt.Errorf("auth filestore: %w", errWeight)
}
s.mu.Lock()
defer s.mu.Unlock()
path, err := s.resolveAuthPath(auth)
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID)
}
if auth.Disabled {
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
return "", nil
}
}
if err = s.ensureRepositoryLocked(); err != nil {
return "", err
}
relPath, errRel := s.relativeToRepo(path)
if errRel != nil {
return "", errRel
}
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", fmt.Errorf("auth filestore: create dir failed: %w", err)
}
switch {
case auth.Storage != nil:
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["disabled"] = auth.Disabled
if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok {
setter.SetMetadata(auth.Metadata)
}
if err = auth.Storage.SaveTokenToFile(path); err != nil {
return "", err
}
case auth.Metadata != nil:
auth.Metadata["disabled"] = auth.Disabled
raw, errMarshal := json.Marshal(auth.Metadata)
if errMarshal != nil {
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
}
contentsMatch := false
if existing, errRead := os.ReadFile(path); errRead == nil {
contentsMatch = jsonEqual(existing, raw)
} else if !os.IsNotExist(errRead) {
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
}
if !contentsMatch {
tmp := path + ".tmp"
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite)
}
if errRename := os.Rename(tmp, path); errRename != nil {
return "", fmt.Errorf("auth filestore: rename failed: %w", errRename)
}
}
default:
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
}
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
auth.Attributes[cliproxyauth.AttributePath] = path
auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceGit
if strings.TrimSpace(auth.FileName) == "" {
auth.FileName = auth.ID
}
messageID := auth.ID
if strings.TrimSpace(messageID) == "" {
messageID = filepath.Base(path)
}
if errCommit := s.commitAndPushLocked(fmt.Sprintf("Update auth %s", strings.TrimSpace(messageID)), relPath); errCommit != nil {
return "", errCommit
}
return path, nil
}
// List enumerates all auth JSON files under the configured directory.
func (s *GitTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.ensureRepositoryLocked(); err != nil {
return nil, err
}
dir := s.baseDirSnapshot()
if dir == "" {
return nil, fmt.Errorf("auth filestore: directory not configured")
}
entries := make([]*cliproxyauth.Auth, 0)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
return nil
}
auth, err := s.readAuthFile(path, dir)
if err != nil {
return nil
}
if auth != nil {
entries = append(entries, auth)
}
return nil
})
if err != nil {
return nil, err
}
return entries, nil
}
// Delete removes the auth file.
func (s *GitTokenStore) Delete(_ context.Context, id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("auth filestore: id is empty")
}
s.mu.Lock()
defer s.mu.Unlock()
path, err := s.resolveDeletePath(id)
if err != nil {
return err
}
if err = s.ensureRepositoryLocked(); err != nil {
return err
}
rel, errRel := s.relativeToRepo(path)
if errRel != nil {
return errRel
}
if err = os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("auth filestore: delete failed: %w", err)
}
messageID := id
if errCommit := s.commitAndPushLocked(fmt.Sprintf("Delete auth %s", messageID), rel); errCommit != nil {
return errCommit
}
return nil
}
// PersistAuthFiles commits and pushes the provided paths to the remote repository.
// It no-ops when the store is not fully configured or when there are no paths.
func (s *GitTokenStore) PersistAuthFiles(_ context.Context, message string, paths ...string) error {
if len(paths) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
filtered := make([]string, 0, len(paths))
for _, p := range paths {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
continue
}
rel, err := s.relativeToRepo(trimmed)
if err != nil {
return err
}
filtered = append(filtered, rel)
}
if len(filtered) == 0 {
return nil
}
if strings.TrimSpace(message) == "" {
message = "Sync watcher updates"
}
// Inspect watcher removals before EnsureRepository restores missing tracked
// files so an unexpected filesystem event remains distinguishable from Delete.
if _, errStat := os.Stat(filepath.Join(s.repoDirSnapshot(), ".git")); errStat == nil {
if handled, errGuard := s.guardWatcherAuthRemovalLocked(message, filtered); handled || errGuard != nil {
return errGuard
}
} else if !errors.Is(errStat, fs.ErrNotExist) {
return fmt.Errorf("git token store: stat repository before watcher removal guard: %w", errStat)
}
if err := s.ensureRepositoryLocked(); err != nil {
return err
}
if handled, errGuard := s.guardWatcherAuthRemovalLocked(message, filtered); handled || errGuard != nil {
return errGuard
}
return s.commitAndPushLocked(message, filtered...)
}
func (s *GitTokenStore) guardWatcherAuthRemovalLocked(message string, relPaths []string) (handled bool, errResult error) {
if !strings.HasPrefix(strings.TrimSpace(message), "Remove auth ") {
return false, nil
}
repoDir := s.repoDirSnapshot()
if repoDir == "" {
return true, fmt.Errorf("git token store: repository path not configured")
}
repo, errOpen := git.PlainOpen(repoDir)
if errOpen != nil {
return true, fmt.Errorf("git token store: open repo for watcher removal guard: %w", errOpen)
}
defer func() {
if errClose := repo.Close(); errClose != nil {
errCloseWrap := fmt.Errorf("git token store: close repo for watcher removal guard: %w", errClose)
if errResult == nil {
errResult = errCloseWrap
} else {
errResult = errors.Join(errResult, errCloseWrap)
}
}
}()
head, errHead := repo.Head()
if errHead != nil {
if errors.Is(errHead, plumbing.ErrReferenceNotFound) {
return true, nil
}
return true, fmt.Errorf("git token store: inspect head for watcher removal guard: %w", errHead)
}
commit, errCommit := repo.CommitObject(head.Hash())
if errCommit != nil {
return true, fmt.Errorf("git token store: inspect commit for watcher removal guard: %w", errCommit)
}
tree, errTree := commit.Tree()
if errTree != nil {
return true, fmt.Errorf("git token store: inspect tree for watcher removal guard: %w", errTree)
}
hasExistingPath := false
for _, rel := range relPaths {
cleanRel := filepath.ToSlash(filepath.Clean(rel))
worktreePath := filepath.Join(repoDir, filepath.FromSlash(cleanRel))
if _, errStat := os.Stat(worktreePath); errStat == nil {
hasExistingPath = true
continue
} else if !errors.Is(errStat, fs.ErrNotExist) {
return true, fmt.Errorf("git token store: stat watcher removal path %s: %w", cleanRel, errStat)
}
if _, errFile := tree.File(cleanRel); errFile == nil {
return true, fmt.Errorf("git token store: refusing watcher-originated removal of tracked auth %s; use an explicit delete", cleanRel)
} else if !errors.Is(errFile, object.ErrFileNotFound) {
return true, fmt.Errorf("git token store: inspect watcher removal path %s: %w", cleanRel, errFile)
}
}
if hasExistingPath {
return false, nil
}
// Explicit GitTokenStore.Delete already removed the path from HEAD. The
// subsequent filesystem watcher event is therefore redundant and safe to ignore.
return true, nil
}
func (s *GitTokenStore) resolveDeletePath(id string) (string, error) {
if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
return id, nil
}
dir := s.baseDirSnapshot()
if dir == "" {
return "", fmt.Errorf("auth filestore: directory not configured")
}
return filepath.Join(dir, id), nil
}
func (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
if len(data) == 0 {
return nil, nil
}
metadata := make(map[string]any)
if err = json.Unmarshal(data, &metadata); err != nil {
return nil, fmt.Errorf("unmarshal auth json: %w", err)
}
cliproxyauth.NormalizeCredentialMetadata(metadata)
if errWeight := cliproxyauth.ValidateAuthWeight(&cliproxyauth.Auth{Metadata: metadata}); errWeight != nil {
return nil, errWeight
}
provider, _ := metadata["type"].(string)
if provider == "" {
provider = "unknown"
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}
id := s.idFor(path, baseDir)
auth := &cliproxyauth.Auth{
ID: id,
Provider: provider,
FileName: id,
Label: s.labelFor(metadata),
Status: cliproxyauth.StatusActive,
Attributes: map[string]string{
cliproxyauth.AttributePath: path,
cliproxyauth.AttributeSourceBackend: cliproxyauth.AuthSourceGit,
},
Metadata: metadata,
CreatedAt: info.ModTime(),
UpdatedAt: info.ModTime(),
LastRefreshedAt: time.Time{},
NextRefreshAfter: time.Time{},
}
if email, ok := metadata["email"].(string); ok && email != "" {
auth.Attributes["email"] = email
}
cliproxyauth.ApplyCustomHeadersFromMetadata(auth)
if disabled, ok := metadata["disabled"].(bool); ok && disabled {
auth.Disabled = true
auth.Status = cliproxyauth.StatusDisabled
}
return auth, nil
}
func (s *GitTokenStore) idFor(path, baseDir string) string {
if baseDir == "" {
return path
}
rel, err := filepath.Rel(baseDir, path)
if err != nil {
return path
}
return rel
}
func (s *GitTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("auth filestore: auth is nil")
}
if auth.Attributes != nil {
if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
return p, nil
}
}
if fileName := strings.TrimSpace(auth.FileName); fileName != "" {
if filepath.IsAbs(fileName) {
return fileName, nil
}
if dir := s.baseDirSnapshot(); dir != "" {
return filepath.Join(dir, fileName), nil
}
return fileName, nil
}
if auth.ID == "" {
return "", fmt.Errorf("auth filestore: missing id")
}
if filepath.IsAbs(auth.ID) {
return auth.ID, nil
}
dir := s.baseDirSnapshot()
if dir == "" {
return "", fmt.Errorf("auth filestore: directory not configured")
}
return filepath.Join(dir, auth.ID), nil
}
func (s *GitTokenStore) labelFor(metadata map[string]any) string {
if metadata == nil {
return ""
}
if v, ok := metadata["label"].(string); ok && v != "" {
return v
}
if v, ok := metadata["email"].(string); ok && v != "" {
return v
}
if project, ok := metadata["project_id"].(string); ok && project != "" {
return project
}
return ""
}
func (s *GitTokenStore) baseDirSnapshot() string {
s.dirLock.RLock()
defer s.dirLock.RUnlock()
return s.baseDir
}
func (s *GitTokenStore) repoDirSnapshot() string {
s.dirLock.RLock()
defer s.dirLock.RUnlock()
return s.repoDir
}
func disableGitCommitSigning(repoDir string) (errResult error) {
repo, errOpen := git.PlainOpen(repoDir)
if errOpen != nil {
return fmt.Errorf("git token store: open repository config: %w", errOpen)
}
defer func() {
if errClose := repo.Close(); errClose != nil {
errCloseWrap := fmt.Errorf("git token store: close repository config: %w", errClose)
if errResult == nil {
errResult = errCloseWrap
} else {
errResult = errors.Join(errResult, errCloseWrap)
}
}
}()
cfg, errConfig := repo.Config()
if errConfig != nil {
return fmt.Errorf("git token store: get repository config: %w", errConfig)
}
cfg.Commit.GpgSign = config.OptBoolFalse
if errSetConfig := repo.SetConfig(cfg); errSetConfig != nil {
return fmt.Errorf("git token store: disable commit signing: %w", errSetConfig)
}
return nil
}
func (s *GitTokenStore) gitClientOptions() []client.Option {
if s.username == "" && s.password == "" {
return nil
}
user := s.username
if user == "" {
user = "git"
}
return []client.Option{client.WithHTTPAuth(&http.BasicAuth{Username: user, Password: s.password})}
}
func (s *GitTokenStore) relativeToRepo(path string) (string, error) {
repoDir := s.repoDirSnapshot()
if repoDir == "" {
return "", fmt.Errorf("git token store: repository path not configured")
}
absRepo, errRepo := filepath.Abs(repoDir)
if errRepo != nil {
return "", fmt.Errorf("git token store: resolve repository path: %w", errRepo)
}
absPath, errPath := filepath.Abs(path)
if errPath != nil {
return "", fmt.Errorf("git token store: resolve path: %w", errPath)
}
rel, errRel := filepath.Rel(absRepo, absPath)
if errRel != nil {
return "", fmt.Errorf("git token store: relative path: %w", errRel)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("git token store: path outside repository")
}
return rel, nil
}
func (s *GitTokenStore) checkoutConfiguredBranch(repo *git.Repository, worktree *git.Worktree, authMethod []client.Option) error {
branchRefName := plumbing.NewBranchReferenceName(s.branch)
headRef, errHead := repo.Head()
switch {
case errHead == nil && headRef.Name() == branchRefName:
return nil
case errHead != nil && !errors.Is(errHead, plumbing.ErrReferenceNotFound):
return fmt.Errorf("git token store: get head: %w", errHead)
}
if err := worktree.Checkout(&git.CheckoutOptions{Branch: branchRefName}); err == nil {
return nil
} else if _, errRef := repo.Reference(branchRefName, true); errRef == nil {
return fmt.Errorf("git token store: checkout branch %s: %w", s.branch, err)
} else if !errors.Is(errRef, plumbing.ErrReferenceNotFound) {
return fmt.Errorf("git token store: inspect branch %s: %w", s.branch, errRef)
} else if err := s.checkoutConfiguredRemoteTrackingBranch(repo, worktree, branchRefName, authMethod); err != nil {
return fmt.Errorf("git token store: checkout branch %s: %w", s.branch, err)
}
return nil
}
func (s *GitTokenStore) checkoutConfiguredRemoteTrackingBranch(repo *git.Repository, worktree *git.Worktree, branchRefName plumbing.ReferenceName, authMethod []client.Option) error {
remoteRefName := plumbing.ReferenceName("refs/remotes/origin/" + s.branch)
remoteRef, err := repo.Reference(remoteRefName, true)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
if errSync := syncRemoteReferences(repo, authMethod); errSync != nil {
return fmt.Errorf("sync remote refs: %w", errSync)
}
remoteRef, err = repo.Reference(remoteRefName, true)
}
if err != nil {
return err
}
if err := worktree.Checkout(&git.CheckoutOptions{Branch: branchRefName, Create: true, Hash: remoteRef.Hash()}); err != nil {
return err
}
cfg, err := repo.Config()
if err != nil {
return fmt.Errorf("git token store: repo config: %w", err)
}
if _, ok := cfg.Branches[s.branch]; !ok {
cfg.Branches[s.branch] = &config.Branch{Name: s.branch}
}
cfg.Branches[s.branch].Remote = "origin"
cfg.Branches[s.branch].Merge = branchRefName
if err := repo.SetConfig(cfg); err != nil {
return fmt.Errorf("git token store: set branch config: %w", err)
}
return nil
}
func syncRemoteReferences(repo *git.Repository, authMethod []client.Option) error {
if err := repo.Fetch(&git.FetchOptions{ClientOptions: authMethod, RemoteName: "origin"}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
return err
}
return nil
}
// resolveRemoteDefaultBranch queries the origin remote to determine the remote's default branch
// (the target of HEAD) and returns the corresponding local branch reference name (e.g. refs/heads/master).
func resolveRemoteDefaultBranch(repo *git.Repository, authMethod []client.Option) (resolvedRemoteBranch, error) {
if err := syncRemoteReferences(repo, authMethod); err != nil {
return resolvedRemoteBranch{}, fmt.Errorf("resolve remote default: sync remote refs: %w", err)
}
remote, err := repo.Remote("origin")
if err != nil {
return resolvedRemoteBranch{}, fmt.Errorf("resolve remote default: get remote: %w", err)
}
refs, err := remote.List(&git.ListOptions{ClientOptions: authMethod})
if err != nil {
if resolved, ok := resolveRemoteDefaultBranchFromLocal(repo); ok {
return resolved, nil
}
return resolvedRemoteBranch{}, fmt.Errorf("resolve remote default: list remote refs: %w", err)
}
for _, r := range refs {
if r.Name() == plumbing.HEAD {
if r.Type() == plumbing.SymbolicReference {
if target, ok := normalizeRemoteBranchReference(r.Target()); ok {
return resolvedRemoteBranch{name: target}, nil
}
}
s := r.String()
if idx := strings.Index(s, "->"); idx != -1 {
if target, ok := normalizeRemoteBranchReference(plumbing.ReferenceName(strings.TrimSpace(s[idx+2:]))); ok {
return resolvedRemoteBranch{name: target}, nil
}
}
}
}
if resolved, ok := resolveRemoteDefaultBranchFromLocal(repo); ok {
return resolved, nil
}
for _, r := range refs {
if normalized, ok := normalizeRemoteBranchReference(r.Name()); ok {
return resolvedRemoteBranch{name: normalized, hash: r.Hash()}, nil
}
}
return resolvedRemoteBranch{}, fmt.Errorf("resolve remote default: remote default branch not found")
}
func resolveRemoteDefaultBranchFromLocal(repo *git.Repository) (resolvedRemoteBranch, bool) {
ref, err := repo.Reference(plumbing.ReferenceName("refs/remotes/origin/HEAD"), true)
if err != nil || ref.Type() != plumbing.SymbolicReference {
return resolvedRemoteBranch{}, false
}
target, ok := normalizeRemoteBranchReference(ref.Target())
if !ok {
return resolvedRemoteBranch{}, false
}
return resolvedRemoteBranch{name: target}, true
}
func normalizeRemoteBranchReference(name plumbing.ReferenceName) (plumbing.ReferenceName, bool) {
switch {
case strings.HasPrefix(name.String(), "refs/heads/"):
return name, true
case strings.HasPrefix(name.String(), "refs/remotes/origin/"):
return plumbing.NewBranchReferenceName(strings.TrimPrefix(name.String(), "refs/remotes/origin/")), true
default:
return "", false
}
}
func resetIndexToHead(repo *git.Repository, worktree *git.Worktree) error {
if repo == nil || worktree == nil {
return fmt.Errorf("repository or worktree is nil")
}
head, errHead := repo.Head()
if errHead != nil {
if errors.Is(errHead, plumbing.ErrReferenceNotFound) {
return nil
}
return errHead
}
return worktree.Reset(&git.ResetOptions{Mode: git.MixedReset, Commit: head.Hash()})
}
func worktreeDirtyPaths(worktree *git.Worktree) (map[string]struct{}, error) {
if worktree == nil {
return nil, fmt.Errorf("worktree is nil")
}
status, errStatus := worktree.Status()
if errStatus != nil {
return nil, errStatus
}
dirtyPaths := make(map[string]struct{}, len(status))
for path, fileStatus := range status {
if fileStatus.Staging == git.Unmodified && fileStatus.Worktree == git.Unmodified {
continue
}
dirtyPaths[filepath.ToSlash(filepath.Clean(path))] = struct{}{}
}
return dirtyPaths, nil
}
func reconcileRemoteWorktree(repo *git.Repository, worktree *git.Worktree, repoDir string, baseRef *plumbing.Reference, dirtyPaths map[string]struct{}) error {
if repo == nil || worktree == nil || baseRef == nil {
return fmt.Errorf("repository, worktree, or base reference is nil")
}
if !baseRef.Name().IsBranch() {
return fmt.Errorf("head %s is not a branch", baseRef.Name())
}
remoteName := plumbing.NewRemoteReferenceName("origin", baseRef.Name().Short())
remoteRef, errRemote := repo.Reference(remoteName, true)
if errRemote != nil {
return fmt.Errorf("resolve remote branch %s: %w", remoteName, errRemote)
}
baseCommit, errBaseCommit := repo.CommitObject(baseRef.Hash())
if errBaseCommit != nil {
return fmt.Errorf("inspect pre-pull commit: %w", errBaseCommit)
}
baseTree, errBaseTree := baseCommit.Tree()
if errBaseTree != nil {
return fmt.Errorf("inspect pre-pull tree: %w", errBaseTree)
}
remoteCommit, errRemoteCommit := repo.CommitObject(remoteRef.Hash())
if errRemoteCommit != nil {
return fmt.Errorf("inspect remote commit: %w", errRemoteCommit)
}
remoteTree, errRemoteTree := remoteCommit.Tree()
if errRemoteTree != nil {
return fmt.Errorf("inspect remote tree: %w", errRemoteTree)
}
changedPaths, errChangedPaths := changedTreePaths(baseTree, remoteTree)
if errChangedPaths != nil {
return errChangedPaths
}
for _, changedPath := range changedPaths {
if dirtyPath, conflict := overlappingDirtyPath(changedPath, dirtyPaths); conflict {
if errRestore := restoreHeadAndIndex(repo, worktree, baseRef); errRestore != nil {
return errors.Join(
fmt.Errorf("remote path %s conflicts with local change %s", changedPath, dirtyPath),
fmt.Errorf("restore pre-pull head after conflict: %w", errRestore),
)
}
return fmt.Errorf("remote path %s conflicts with local change %s", changedPath, dirtyPath)
}
}
// Pull moves HEAD before reporting unstaged changes. Return to the pre-pull
// tree before applying only remote changes that do not overlap local edits.
if errRestore := restoreHeadAndIndex(repo, worktree, baseRef); errRestore != nil {
return fmt.Errorf("restore pre-pull head: %w", errRestore)
}
if errApply := applyTreePaths(remoteTree, repoDir, changedPaths); errApply != nil {
if errRollback := applyTreePaths(baseTree, repoDir, changedPaths); errRollback != nil {
return errors.Join(
fmt.Errorf("apply remote worktree changes: %w", errApply),
fmt.Errorf("restore pre-pull worktree: %w", errRollback),
)
}
return fmt.Errorf("apply remote worktree changes: %w", errApply)
}
if errReference := repo.Storer.SetReference(plumbing.NewHashReference(baseRef.Name(), remoteRef.Hash())); errReference != nil {
if errRollback := applyTreePaths(baseTree, repoDir, changedPaths); errRollback != nil {
return errors.Join(
fmt.Errorf("update branch %s: %w", baseRef.Name(), errReference),
fmt.Errorf("restore pre-pull worktree: %w", errRollback),
)
}
return fmt.Errorf("update branch %s: %w", baseRef.Name(), errReference)
}
if errReset := worktree.Reset(&git.ResetOptions{Mode: git.MixedReset, Commit: remoteRef.Hash()}); errReset != nil {
return fmt.Errorf("reset index to remote branch %s: %w", remoteName, errReset)
}
return nil
}
func changedTreePaths(baseTree, remoteTree *object.Tree) ([]string, error) {
changes, errDiff := baseTree.Diff(remoteTree)
if errDiff != nil {
return nil, fmt.Errorf("compare pre-pull and remote trees: %w", errDiff)
}
paths := make(map[string]struct{}, len(changes))
for _, change := range changes {
for _, path := range []string{change.From.Name, change.To.Name} {
if path == "" {
continue
}
paths[filepath.ToSlash(filepath.Clean(path))] = struct{}{}
}
}
changedPaths := make([]string, 0, len(paths))
for path := range paths {
changedPaths = append(changedPaths, path)
}
sort.Strings(changedPaths)
return changedPaths, nil
}
func overlappingDirtyPath(path string, dirtyPaths map[string]struct{}) (string, bool) {
for dirtyPath := range dirtyPaths {
if path == dirtyPath || strings.HasPrefix(path, dirtyPath+"/") || strings.HasPrefix(dirtyPath, path+"/") {
return dirtyPath, true
}
}
return "", false
}
func applyTreePaths(tree *object.Tree, repoDir string, paths []string) error {
for _, path := range paths {
destination := filepath.Join(repoDir, filepath.FromSlash(path))
file, errFile := tree.File(path)
if errors.Is(errFile, object.ErrFileNotFound) {
if errRemove := os.Remove(destination); errRemove != nil && !errors.Is(errRemove, fs.ErrNotExist) {
return fmt.Errorf("remove %s: %w", path, errRemove)
}
continue
}
if errFile != nil {
return fmt.Errorf("inspect %s: %w", path, errFile)
}
contents, errContents := file.Contents()
if errContents != nil {
return fmt.Errorf("read %s: %w", path, errContents)
}
if errMkdir := os.MkdirAll(filepath.Dir(destination), 0o700); errMkdir != nil {
return fmt.Errorf("create parent for %s: %w", path, errMkdir)
}
if errWrite := os.WriteFile(destination, []byte(contents), 0o600); errWrite != nil {
return fmt.Errorf("write %s: %w", path, errWrite)
}
}
return nil
}
func closeRecoveryRepositories(closeRepository func(*git.Repository) error, baselineRepo, clonedRepo *git.Repository) error {
var errClose error
if baselineRepo != nil {
if err := closeRepository(baselineRepo); err != nil {
errClose = errors.Join(errClose, fmt.Errorf("close recovery baseline repository: %w", err))
}
}
if clonedRepo != nil {
if err := closeRepository(clonedRepo); err != nil {
errClose = errors.Join(errClose, fmt.Errorf("close cloned recovery repository: %w", err))
}
}
return errClose
}
func (s *GitTokenStore) recoverRepositoryLocked(repoDir string, authMethod []client.Option, callerRepo *git.Repository, baselineTree *object.Tree, dirtyPaths map[string]struct{}, closeRepository func(*git.Repository) error, renameWorktreeEntry func(string, string) error) (errRecovery error) {
parentDir := filepath.Dir(repoDir)
recoveryRoot, errTemp := os.MkdirTemp(parentDir, ".gitstore-recovery-")
if errTemp != nil {
if callerRepo != nil {
if errClose := closeRepository(callerRepo); errClose != nil {
return errors.Join(
fmt.Errorf("create recovery directory: %w", errTemp),
fmt.Errorf("close recovery caller repository: %w", errClose),
)
}
}
return fmt.Errorf("create recovery directory: %w", errTemp)
}
cleanupRecovery := true
defer func() {
if !cleanupRecovery {
return
}
if errRemove := os.RemoveAll(recoveryRoot); errRemove != nil {
errCleanup := fmt.Errorf("remove recovery directory: %w", errRemove)
if errRecovery == nil {
errRecovery = errCleanup
} else {
errRecovery = errors.Join(errRecovery, errCleanup)
}
}
}()
var baselineRepo *git.Repository
if baselineTree == nil {
if callerRepo != nil {
if errClose := closeRepository(callerRepo); errClose != nil {
return fmt.Errorf("close recovery caller repository before baseline inspection: %w", errClose)
}
}
inspectedRepo, inspectedTree, inspectedDirtyPaths, errInspect := inspectRecoveryBaseline(repoDir)
if errInspect != nil {
return fmt.Errorf("inspect recovery baseline: %w", errInspect)
}
baselineRepo = inspectedRepo
baselineTree = inspectedTree
dirtyPaths = inspectedDirtyPaths
} else {
baselineRepo = callerRepo
}
cloneDir := filepath.Join(recoveryRoot, "clone")
cloneOpts := &git.CloneOptions{ClientOptions: authMethod, URL: s.remote}
if s.branch != "" {
cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch)
}
clonedRepo, errClone := git.PlainClone(cloneDir, cloneOpts)
if errClone != nil {
if baselineRepo != nil {
if errClose := closeRepository(baselineRepo); errClose != nil {
return errors.Join(
fmt.Errorf("clone remote repository: %w", errClone),
fmt.Errorf("close recovery baseline repository: %w", errClose),
)
}
}
return fmt.Errorf("clone remote repository: %w", errClone)
}
defer func() {
if errClose := closeRecoveryRepositories(closeRepository, baselineRepo, clonedRepo); errClose != nil {
errRecovery = errors.Join(errRecovery, errClose)
}
}()
if errVerify := verifyRepositoryHead(clonedRepo); errVerify != nil {
return fmt.Errorf("verify cloned repository: %w", errVerify)
}
clonedHead, errHead := clonedRepo.Head()
if errHead != nil {
return fmt.Errorf("get cloned repository head: %w", errHead)
}
clonedCommit, errCommit := clonedRepo.CommitObject(clonedHead.Hash())
if errCommit != nil {
return fmt.Errorf("inspect cloned repository head: %w", errCommit)
}
remoteTree, errTree := clonedCommit.Tree()
if errTree != nil {
return fmt.Errorf("inspect cloned repository tree: %w", errTree)
}
preservedPaths, errPreserve := recoveryPreservedPaths(baselineTree, remoteTree, dirtyPaths)
if errPreserve != nil {
return errPreserve
}
if errApply := applyRecoveryLocalChanges(repoDir, cloneDir, preservedPaths); errApply != nil {
return fmt.Errorf("preserve local worktree changes: %w", errApply)
}
errClose := closeRecoveryRepositories(closeRepository, baselineRepo, clonedRepo)
baselineRepo = nil
clonedRepo = nil
if errClose != nil {
return errClose
}
backupWorktreeDir := filepath.Join(recoveryRoot, "worktree")
retainWorktreeBackup, errBackup := moveWorktreeEntries(repoDir, backupWorktreeDir, renameWorktreeEntry)
if errBackup != nil {
if retainWorktreeBackup {
cleanupRecovery = false
return fmt.Errorf("backup existing worktree; backup retained at %s: %w", backupWorktreeDir, errBackup)
}
return fmt.Errorf("backup existing worktree: %w", errBackup)
}
gitDir := filepath.Join(repoDir, ".git")
clonedGitDir := filepath.Join(cloneDir, ".git")
backupGitDir := filepath.Join(recoveryRoot, "corrupt.git")
retainRecovery, errInstall := installRecoveredGitDirectory(gitDir, clonedGitDir, backupGitDir, os.Rename)
if retainRecovery {
cleanupRecovery = false
}
if errInstall != nil {
if _, errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir, renameWorktreeEntry); errRestore != nil {
cleanupRecovery = false
return errors.Join(errInstall, fmt.Errorf("restore worktree; backup retained at %s: %w", backupWorktreeDir, errRestore))
}
return errInstall
}
if _, errMove := moveWorktreeEntries(cloneDir, repoDir, renameWorktreeEntry); errMove != nil {
errMoveWorktree := fmt.Errorf("install recovered worktree: %w", errMove)
if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir, renameWorktreeEntry); errRollback != nil {
cleanupRecovery = false
return errors.Join(errMoveWorktree, fmt.Errorf("rollback recovered repository; backup retained at %s: %w", recoveryRoot, errRollback))
}
return errMoveWorktree
}
recoveredRepo, errOpen := git.PlainOpen(repoDir)
if errOpen == nil {
errVerify := verifyRepositoryHead(recoveredRepo)
errClose := closeRepository(recoveredRepo)
if errVerify != nil && errClose != nil {
errOpen = errors.Join(errVerify, fmt.Errorf("close recovered repository: %w", errClose))
} else if errVerify != nil {
errOpen = errVerify
} else if errClose != nil {
errOpen = fmt.Errorf("close recovered repository: %w", errClose)
}
}
if errOpen != nil {
errRecovered := fmt.Errorf("verify recovered repository: %w", errOpen)
if errRollback := rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir, renameWorktreeEntry); errRollback != nil {
cleanupRecovery = false
return errors.Join(errRecovered, fmt.Errorf("rollback recovered repository; backup retained at %s: %w", recoveryRoot, errRollback))
}
return errRecovered
}
return nil
}
func inspectRecoveryBaseline(repoDir string) (repoResult *git.Repository, treeResult *object.Tree, dirtyResult map[string]struct{}, errResult error) {
openedRepo, errOpen := git.PlainOpen(repoDir)
if errOpen != nil {
return nil, nil, nil, fmt.Errorf("open repository: %w", errOpen)
}
defer func() {
if errResult != nil && openedRepo != nil {
if errClose := openedRepo.Close(); errClose != nil {
errResult = errors.Join(errResult, fmt.Errorf("close baseline repository on inspection failure: %w", errClose))
}
openedRepo = nil
}
}()
worktree, errWorktree := openedRepo.Worktree()
if errWorktree != nil {
return nil, nil, nil, fmt.Errorf("open worktree: %w", errWorktree)
}
dirtyPaths, errDirty := worktreeDirtyPaths(worktree)
if errDirty != nil {
return nil, nil, nil, fmt.Errorf("inspect worktree changes: %w", errDirty)
}
head, errHead := openedRepo.Head()
if errHead != nil {
return nil, nil, nil, fmt.Errorf("inspect head: %w", errHead)
}
commit, errCommit := openedRepo.CommitObject(head.Hash())
if errCommit != nil {
return nil, nil, nil, fmt.Errorf("inspect head commit: %w", errCommit)
}
tree, errTree := commit.Tree()
if errTree != nil {
return nil, nil, nil, fmt.Errorf("inspect head tree: %w", errTree)
}
return openedRepo, tree, dirtyPaths, nil
}
func recoveryPreservedPaths(baselineTree, remoteTree *object.Tree, dirtyPaths map[string]struct{}) (map[string]struct{}, error) {
if baselineTree == nil || len(dirtyPaths) == 0 {
return nil, nil
}
changedPaths, errChanged := changedTreePaths(baselineTree, remoteTree)
if errChanged != nil {
return nil, fmt.Errorf("verify local changes against recovered remote: %w", errChanged)
}
for _, changedPath := range changedPaths {
if dirtyPath, conflict := overlappingDirtyPath(changedPath, dirtyPaths); conflict {
return nil, fmt.Errorf("remote path %s conflicts with local change %s during repository recovery", changedPath, dirtyPath)
}
}
return dirtyPaths, nil
}
func applyRecoveryLocalChanges(sourceDir, targetDir string, paths map[string]struct{}) error {
sortedPaths := make([]string, 0, len(paths))
for path := range paths {
sortedPaths = append(sortedPaths, path)
}
sort.Strings(sortedPaths)
for _, path := range sortedPaths {
source := filepath.Join(sourceDir, filepath.FromSlash(path))
target := filepath.Join(targetDir, filepath.FromSlash(path))
info, errStat := os.Lstat(source)
if errors.Is(errStat, fs.ErrNotExist) {
if errRemove := os.RemoveAll(target); errRemove != nil {
return fmt.Errorf("preserve deletion %s: %w", path, errRemove)
}
continue
}
if errStat != nil {
return fmt.Errorf("inspect local change %s: %w", path, errStat)
}
if errRemove := os.RemoveAll(target); errRemove != nil {
return fmt.Errorf("replace recovered path %s: %w", path, errRemove)
}
if errMkdir := os.MkdirAll(filepath.Dir(target), 0o700); errMkdir != nil {
return fmt.Errorf("create recovered parent for %s: %w", path, errMkdir)
}
switch {
case info.Mode().IsRegular():
contents, errRead := os.ReadFile(source)
if errRead != nil {
return fmt.Errorf("read local change %s: %w", path, errRead)
}
if errWrite := os.WriteFile(target, contents, info.Mode().Perm()); errWrite != nil {
return fmt.Errorf("write local change %s: %w", path, errWrite)
}
case info.Mode()&os.ModeSymlink != 0:
linkTarget, errReadlink := os.Readlink(source)
if errReadlink != nil {
return fmt.Errorf("read local symlink %s: %w", path, errReadlink)
}
if errSymlink := os.Symlink(linkTarget, target); errSymlink != nil {
return fmt.Errorf("write local symlink %s: %w", path, errSymlink)
}
default:
return fmt.Errorf("local change %s has unsupported file mode %s", path, info.Mode())
}
}
return nil
}
func moveWorktreeEntries(sourceDir, targetDir string, rename func(string, string) error) (bool, error) {
if rename == nil {
return false, fmt.Errorf("rename function is nil")
}
if errMkdir := os.MkdirAll(targetDir, 0o700); errMkdir != nil {
return false, errMkdir
}
entries, errRead := os.ReadDir(sourceDir)
if errRead != nil {
return false, errRead
}
moved := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Name() == ".git" {
continue
}
source := filepath.Join(sourceDir, entry.Name())
target := filepath.Join(targetDir, entry.Name())
if errRename := rename(source, target); errRename != nil {
errMove := fmt.Errorf("move %s: %w", entry.Name(), errRename)
retainTarget := false
for index := len(moved) - 1; index >= 0; index-- {
name := moved[index]
if errRestore := rename(filepath.Join(targetDir, name), filepath.Join(sourceDir, name)); errRestore != nil {
retainTarget = true
errMove = errors.Join(errMove, fmt.Errorf("restore %s: %w", name, errRestore))
}
}
return retainTarget, errMove
}
moved = append(moved, entry.Name())
}
return false, nil
}
func removeWorktreeEntries(repoDir string) error {
entries, errRead := os.ReadDir(repoDir)
if errRead != nil {
return errRead
}
for _, entry := range entries {
if entry.Name() == ".git" {
continue
}
if errRemove := os.RemoveAll(filepath.Join(repoDir, entry.Name())); errRemove != nil {
return errRemove
}
}
return nil
}
func rollbackRecoveredRepository(repoDir, gitDir, backupGitDir, backupWorktreeDir string, renameWorktreeEntry func(string, string) error) error {
if errRemove := removeWorktreeEntries(repoDir); errRemove != nil {
return fmt.Errorf("remove recovered worktree: %w", errRemove)
}
if errRollback := rollbackRecoveredGitDirectory(gitDir, backupGitDir); errRollback != nil {
return errRollback
}
if _, errRestore := moveWorktreeEntries(backupWorktreeDir, repoDir, renameWorktreeEntry); errRestore != nil {
return fmt.Errorf("restore original worktree: %w", errRestore)
}
return nil
}
func installRecoveredGitDirectory(gitDir, clonedGitDir, backupGitDir string, rename func(string, string) error) (bool, error) {
if errRename := rename(gitDir, backupGitDir); errRename != nil {
return false, fmt.Errorf("backup corrupt git directory: %w", errRename)
}
if errRename := rename(clonedGitDir, gitDir); errRename != nil {
if errRestore := rename(backupGitDir, gitDir); errRestore != nil {
return true, errors.Join(
fmt.Errorf("install recovered git directory: %w", errRename),
fmt.Errorf("restore corrupt git directory; backup retained at %s: %w", backupGitDir, errRestore),
)
}
return false, fmt.Errorf("install recovered git directory: %w", errRename)
}
return false, nil
}
func rollbackRecoveredGitDirectory(gitDir, backupGitDir string) error {
if errRemove := os.RemoveAll(gitDir); errRemove != nil {
return fmt.Errorf("remove recovered git directory: %w", errRemove)
}
if errRename := os.Rename(backupGitDir, gitDir); errRename != nil {
return fmt.Errorf("restore original git directory: %w", errRename)
}
return nil
}
func isRepositoryCorruptionError(err error) bool {
return errors.Is(err, dotgit.ErrPackfileNotFound) || errors.Is(err, plumbing.ErrObjectNotFound)
}
func verifyRepositoryHead(repo *git.Repository) error {
if repo == nil {
return fmt.Errorf("repository is nil")
}
head, errHead := repo.Head()
if errHead != nil {
if errors.Is(errHead, plumbing.ErrReferenceNotFound) {
return nil
}
return errHead
}
commit, errCommit := repo.CommitObject(head.Hash())
if errCommit != nil {
return errCommit
}
tree, errTree := commit.Tree()
if errTree != nil {
return errTree
}
files := tree.Files()
return files.ForEach(func(file *object.File) error {
_, errContents := file.Contents()
return errContents
})
}
func restoreMissingTrackedFiles(repo *git.Repository, repoDir string) error {
if repo == nil {
return fmt.Errorf("repository is nil")
}
head, errHead := repo.Head()
if errHead != nil {
if errors.Is(errHead, plumbing.ErrReferenceNotFound) {
return nil
}
return errHead
}
commit, errCommit := repo.CommitObject(head.Hash())
if errCommit != nil {
return errCommit
}
tree, errTree := commit.Tree()
if errTree != nil {
return errTree
}
files := tree.Files()
return files.ForEach(func(file *object.File) error {
destination := filepath.Join(repoDir, filepath.FromSlash(file.Name))
if _, errStat := os.Lstat(destination); errStat == nil {
return nil
} else if !errors.Is(errStat, fs.ErrNotExist) {
return errStat
}
contents, errContents := file.Contents()
if errContents != nil {
return errContents
}
if errMkdir := os.MkdirAll(filepath.Dir(destination), 0o700); errMkdir != nil {
return errMkdir
}
return os.WriteFile(destination, []byte(contents), 0o600)
})
}
func shouldFallbackToCurrentBranch(repo *git.Repository, err error) bool {
if !errors.Is(err, transport.ErrAuthenticationRequired) && !errors.Is(err, transport.ErrEmptyRemoteRepository) {
return false
}
_, headErr := repo.Head()
return headErr == nil
}
// checkoutRemoteDefaultBranch ensures the working tree is checked out to the remote's default branch
// (the branch target of origin/HEAD). If the local branch does not exist it will be created to track
// the remote branch.
func checkoutRemoteDefaultBranch(repo *git.Repository, worktree *git.Worktree, authMethod []client.Option) error {
resolved, err := resolveRemoteDefaultBranch(repo, authMethod)
if err != nil {
return err
}
branchRefName := resolved.name
// If HEAD already points to the desired branch, nothing to do.
headRef, errHead := repo.Head()
if errHead == nil && headRef.Name() == branchRefName {
return nil
}
// If local branch exists, attempt a checkout
if _, err := repo.Reference(branchRefName, true); err == nil {
if err := worktree.Checkout(&git.CheckoutOptions{Branch: branchRefName}); err != nil {
return fmt.Errorf("checkout branch %s: %w", branchRefName.String(), err)
}
return nil
}
// Try to find the corresponding remote tracking ref (refs/remotes/origin/<name>)
branchShort := strings.TrimPrefix(branchRefName.String(), "refs/heads/")
remoteRefName := plumbing.ReferenceName("refs/remotes/origin/" + branchShort)
hash := resolved.hash
if remoteRef, err := repo.Reference(remoteRefName, true); err == nil {
hash = remoteRef.Hash()
} else if err != nil && !errors.Is(err, plumbing.ErrReferenceNotFound) {
return fmt.Errorf("checkout remote default: remote ref %s: %w", remoteRefName.String(), err)
}
if hash == plumbing.ZeroHash {
return fmt.Errorf("checkout remote default: remote ref %s not found", remoteRefName.String())
}
if err := worktree.Checkout(&git.CheckoutOptions{Branch: branchRefName, Create: true, Hash: hash}); err != nil {
return fmt.Errorf("checkout create branch %s: %w", branchRefName.String(), err)
}
cfg, err := repo.Config()
if err != nil {
return fmt.Errorf("git token store: repo config: %w", err)
}
if _, ok := cfg.Branches[branchShort]; !ok {
cfg.Branches[branchShort] = &config.Branch{Name: branchShort}
}
cfg.Branches[branchShort].Remote = "origin"
cfg.Branches[branchShort].Merge = branchRefName
if err := repo.SetConfig(cfg); err != nil {
return fmt.Errorf("git token store: set branch config: %w", err)
}
return nil
}
func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) error {
return s.commitAndPushWithOptionsLocked(message, false, relPaths...)
}
func (s *GitTokenStore) commitAndPushInitialLocked(message string, relPaths ...string) error {
return s.commitAndPushWithOptionsLocked(message, true, relPaths...)
}
func (s *GitTokenStore) commitAndPushWithOptionsLocked(message string, allowMissingRemote bool, relPaths ...string) (errResult error) {
repoDir := s.repoDirSnapshot()
if repoDir == "" {
return fmt.Errorf("git token store: repository path not configured")
}
repo, err := git.PlainOpen(repoDir)
if err != nil {
return fmt.Errorf("git token store: open repo: %w", err)
}
defer func() {
if errClose := repo.Close(); errClose != nil {
errCloseWrap := fmt.Errorf("git token store: close repo: %w", errClose)
if errResult == nil {
errResult = errCloseWrap
} else {
errResult = errors.Join(errResult, errCloseWrap)
}
}
}()
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("git token store: worktree: %w", err)
}
managedPaths, errPaths := normalizeManagedPaths(relPaths)
if errPaths != nil {
return fmt.Errorf("git token store: validate commit paths: %w", errPaths)
}
if len(managedPaths) == 0 {
return nil
}
baseRef, errHead := repo.Head()
if errHead != nil && !errors.Is(errHead, plumbing.ErrReferenceNotFound) {
return fmt.Errorf("git token store: get base head: %w", errHead)
}
if errHead == nil {
if errReset := resetIndexToHead(repo, worktree); errReset != nil {
return fmt.Errorf("git token store: reset index before commit: %w", errReset)
}
}
added := false
for _, rel := range managedPaths {
if _, err = worktree.Add(rel); err != nil {
if errors.Is(err, gitindex.ErrEntryNotFound) {
continue
}
if errors.Is(err, os.ErrNotExist) {
if _, errRemove := worktree.Remove(rel); errRemove != nil {
if errors.Is(errRemove, os.ErrNotExist) || errors.Is(errRemove, gitindex.ErrEntryNotFound) {
continue
}
return fmt.Errorf("git token store: remove %s: %w", rel, errRemove)
}
} else {
return fmt.Errorf("git token store: add %s: %w", rel, err)
}
}
added = true
}
if !added {
return nil
}
status, err := worktree.Status()
if err != nil {
return fmt.Errorf("git token store: status: %w", err)
}
if status.IsClean() {
return nil
}
if strings.TrimSpace(message) == "" {
message = "Update auth store"
}
signature := &object.Signature{
Name: "CLIProxyAPI",
Email: "cliproxy@local",
When: time.Now(),
}
commitHash, err := worktree.Commit(message, &git.CommitOptions{
Author: signature,
})
if err != nil {
if errors.Is(err, git.ErrEmptyCommit) {
return nil
}
return fmt.Errorf("git token store: commit: %w", err)
}
if baseRef != nil {
if errValidate := validateManagedTreeChanges(repo, baseRef.Hash(), commitHash, managedPaths); errValidate != nil {
errRestore := restoreHeadAndIndex(repo, worktree, baseRef)
if errRestore != nil {
return errors.Join(
fmt.Errorf("git token store: validate commit tree: %w", errValidate),
fmt.Errorf("git token store: restore head after rejected commit: %w", errRestore),
)
}
return fmt.Errorf("git token store: validate commit tree: %w", errValidate)
}
}
headRef, errCommittedHead := repo.Head()
if errCommittedHead != nil {
return fmt.Errorf("git token store: get committed head: %w", errCommittedHead)
}
if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {
return errRewrite
}
if errPush := s.pushRepositoryLocked(repo, repoDir, allowMissingRemote); errPush != nil {
if baseRef == nil {
return errPush
}
if errRestore := restoreHeadAndIndex(repo, worktree, baseRef); errRestore != nil {
return errors.Join(errPush, fmt.Errorf("git token store: restore head after rejected push: %w", errRestore))
}
return errPush
}
return nil
}
func normalizeManagedPaths(paths []string) ([]string, error) {
normalized := make([]string, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
continue
}
clean := filepath.ToSlash(filepath.Clean(trimmed))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(trimmed) {
return nil, fmt.Errorf("path %q is not a repository-relative file", path)
}
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
normalized = append(normalized, clean)
}
return normalized, nil
}
func validateManagedTreeChanges(repo *git.Repository, baseHash, commitHash plumbing.Hash, managedPaths []string) error {
baseCommit, errBase := repo.CommitObject(baseHash)
if errBase != nil {
return fmt.Errorf("inspect base commit: %w", errBase)
}
baseTree, errBaseTree := baseCommit.Tree()
if errBaseTree != nil {
return fmt.Errorf("inspect base tree: %w", errBaseTree)
}
commit, errCommit := repo.CommitObject(commitHash)
if errCommit != nil {
return fmt.Errorf("inspect candidate commit: %w", errCommit)
}
candidateTree, errCandidateTree := commit.Tree()
if errCandidateTree != nil {
return fmt.Errorf("inspect candidate tree: %w", errCandidateTree)
}
changes, errDiff := baseTree.Diff(candidateTree)
if errDiff != nil {
return fmt.Errorf("compare candidate tree: %w", errDiff)
}
for _, change := range changes {
for _, changedPath := range []string{change.From.Name, change.To.Name} {
if changedPath == "" || isManagedTreePath(changedPath, managedPaths) {
continue
}
return fmt.Errorf("unexpected indexed change outside requested paths: %s", changedPath)
}
}
return nil
}
func isManagedTreePath(path string, managedPaths []string) bool {
cleanPath := filepath.ToSlash(filepath.Clean(path))
for _, managedPath := range managedPaths {
if cleanPath == managedPath || strings.HasPrefix(cleanPath, managedPath+"/") {
return true
}
}
return false
}
func restoreHeadAndIndex(repo *git.Repository, worktree *git.Worktree, head *plumbing.Reference) error {
if repo == nil || worktree == nil || head == nil {
return fmt.Errorf("repository, worktree, or head is nil")
}
if errReference := repo.Storer.SetReference(plumbing.NewHashReference(head.Name(), head.Hash())); errReference != nil {
return errReference
}
return worktree.Reset(&git.ResetOptions{Mode: git.MixedReset, Commit: head.Hash()})
}
func (s *GitTokenStore) pushRepositoryLocked(repo *git.Repository, repoDir string, allowMissingRemote bool) error {
if repo == nil {
return fmt.Errorf("git token store: repository is nil")
}
headRef, errHead := repo.Head()
if errHead != nil {
if errors.Is(errHead, plumbing.ErrReferenceNotFound) {
return nil
}
return fmt.Errorf("git token store: get head for push: %w", errHead)
}
if !headRef.Name().IsBranch() {
return fmt.Errorf("git token store: head %s is not a branch", headRef.Name())
}
branchName := headRef.Name()
remoteName := plumbing.NewRemoteReferenceName("origin", branchName.Short())
pushOpts := &git.PushOptions{
ClientOptions: s.gitClientOptions(),
RefSpecs: []config.RefSpec{config.RefSpec(branchName.String() + ":" + branchName.String())},
}
remoteRef, errRemote := repo.Reference(remoteName, true)
switch {
case errRemote == nil:
pushOpts.ForceWithLease = &git.ForceWithLease{RefName: branchName, Hash: remoteRef.Hash()}
case errors.Is(errRemote, plumbing.ErrReferenceNotFound) && allowMissingRemote:
// A normal branch-creation push fails if another initializer wins the race.
case errors.Is(errRemote, plumbing.ErrReferenceNotFound):
return fmt.Errorf("git token store: remote tracking branch %s not found", remoteName)
default:
return fmt.Errorf("git token store: inspect remote tracking branch %s: %w", remoteName, errRemote)
}
if errPush := repo.Push(pushOpts); errPush != nil {
if !errors.Is(errPush, git.NoErrAlreadyUpToDate) {
return fmt.Errorf("git token store: push: %w", errPush)
}
}
if errReference := repo.Storer.SetReference(plumbing.NewHashReference(remoteName, headRef.Hash())); errReference != nil {
return fmt.Errorf("git token store: update remote tracking branch %s: %w", remoteName, errReference)
}
s.maybeRunGC(repoDir)
return nil
}
// rewriteHeadAsSingleCommit rewrites the current branch tip to a single-parentless commit and leaves history squashed.
func (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch plumbing.ReferenceName, commitHash plumbing.Hash, message string, signature *object.Signature) error {
commitObj, err := repo.CommitObject(commitHash)
if err != nil {
return fmt.Errorf("git token store: inspect head commit: %w", err)
}
squashed := &object.Commit{
Author: *signature,
Committer: *signature,
Message: message,
TreeHash: commitObj.TreeHash,
ParentHashes: nil,
Encoding: commitObj.Encoding,
ExtraHeaders: commitObj.ExtraHeaders,
}
mem := &plumbing.MemoryObject{}
mem.SetType(plumbing.CommitObject)
if err := squashed.Encode(mem); err != nil {
return fmt.Errorf("git token store: encode squashed commit: %w", err)
}
newHash, err := repo.Storer.SetEncodedObject(mem)
if err != nil {
return fmt.Errorf("git token store: write squashed commit: %w", err)
}
if err := repo.Storer.SetReference(plumbing.NewHashReference(branch, newHash)); err != nil {
return fmt.Errorf("git token store: update branch reference: %w", err)
}
return nil
}
func (s *GitTokenStore) maybeRunGC(repoDir string) {
now := time.Now()
if now.Sub(s.lastGC) < gcInterval {
return
}
s.lastGC = now
repo, err := git.PlainOpen(repoDir)
if err != nil {
log.Warnf("git token store: open repository for GC: %v", err)
return
}
defer func() {
if errClose := repo.Close(); errClose != nil {
log.Warnf("git token store: close repository after GC: %v", errClose)
}
}()
pruneOpts := git.PruneOptions{
OnlyObjectsOlderThan: now.Add(-gcPruneGracePeriod),
Handler: repo.DeleteObject,
}
if err := repo.Prune(pruneOpts); err != nil && !errors.Is(err, git.ErrLooseObjectsNotSupported) {
return
}
_ = repo.RepackObjects(&git.RepackConfig{})
}
// PersistConfig commits and pushes configuration changes to git.
func (s *GitTokenStore) PersistConfig(_ context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.ensureRepositoryLocked(); err != nil {
return err
}
configPath := s.ConfigPath()
if configPath == "" {
return fmt.Errorf("git token store: config path not configured")
}
if _, err := os.Stat(configPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf("git token store: stat config: %w", err)
}
rel, err := s.relativeToRepo(configPath)
if err != nil {
return err
}
return s.commitAndPushLocked("Update config", rel)
}
func ensureEmptyFile(path string) error {
if _, err := os.Stat(path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return os.WriteFile(path, []byte{}, 0o600)
}
return err
}
return nil
}
func jsonEqual(a, b []byte) bool {
var objA any
var objB any
if err := json.Unmarshal(a, &objA); err != nil {
return false
}
if err := json.Unmarshal(b, &objB); err != nil {
return false
}
return deepEqualJSON(objA, objB)
}
func deepEqualJSON(a, b any) bool {
switch valA := a.(type) {
case map[string]any:
valB, ok := b.(map[string]any)
if !ok || len(valA) != len(valB) {
return false
}
for key, subA := range valA {
subB, ok1 := valB[key]
if !ok1 || !deepEqualJSON(subA, subB) {
return false
}
}
return true
case []any:
sliceB, ok := b.([]any)
if !ok || len(valA) != len(sliceB) {
return false
}
for i := range valA {
if !deepEqualJSON(valA[i], sliceB[i]) {
return false
}
}
return true
case float64:
valB, ok := b.(float64)
if !ok {
return false
}
return valA == valB
case string:
valB, ok := b.(string)
if !ok {
return false
}
return valA == valB
case bool:
valB, ok := b.(bool)
if !ok {
return false
}
return valA == valB
case nil:
return b == nil
default:
return false
}
}