mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-12 17:24:13 +00:00
1010 lines
30 KiB
Go
1010 lines
30 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"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/object"
|
|
"github.com/go-git/go-git/v6/plumbing/transport"
|
|
"github.com/go-git/go-git/v6/plumbing/transport/http"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
)
|
|
|
|
// gcInterval defines minimum time between garbage collection runs.
|
|
const gcInterval = 5 * time.Minute
|
|
|
|
// 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) {
|
|
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.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.gitAuth()
|
|
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{Auth: authMethod, URL: s.remote}
|
|
if s.branch != "" {
|
|
cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch)
|
|
}
|
|
if _, 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)
|
|
}
|
|
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 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)
|
|
}
|
|
worktree, errWorktree := repo.Worktree()
|
|
if errWorktree != nil {
|
|
s.dirLock.Unlock()
|
|
return fmt.Errorf("git token store: 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{Auth: authMethod, RemoteName: "origin"}
|
|
if s.branch != "" {
|
|
pullOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch)
|
|
}
|
|
if errPull := worktree.Pull(pullOpts); errPull != nil {
|
|
switch {
|
|
case errors.Is(errPull, git.NoErrAlreadyUpToDate),
|
|
errors.Is(errPull, git.ErrUnstagedChanges),
|
|
errors.Is(errPull, git.ErrNonFastForwardUpdate):
|
|
// Ignore clean syncs, local edits, and remote divergence—local changes win.
|
|
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.
|
|
default:
|
|
s.dirLock.Unlock()
|
|
return fmt.Errorf("git token store: pull: %w", errPull)
|
|
}
|
|
}
|
|
}
|
|
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 {
|
|
s.mu.Lock()
|
|
err := s.commitAndPushLocked("Initialize git token store", initPaths...)
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
|
|
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.EnsureRepository(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
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 err = auth.Storage.SaveTokenToFile(path); err != nil {
|
|
return "", err
|
|
}
|
|
case auth.Metadata != nil:
|
|
raw, errMarshal := json.Marshal(auth.Metadata)
|
|
if errMarshal != nil {
|
|
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
|
|
}
|
|
if existing, errRead := os.ReadFile(path); errRead == nil {
|
|
if jsonEqual(existing, raw) {
|
|
return path, nil
|
|
}
|
|
} else if !os.IsNotExist(errRead) {
|
|
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
|
}
|
|
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["path"] = path
|
|
|
|
if strings.TrimSpace(auth.FileName) == "" {
|
|
auth.FileName = auth.ID
|
|
}
|
|
|
|
relPath, errRel := s.relativeToRepo(path)
|
|
if errRel != nil {
|
|
return "", errRel
|
|
}
|
|
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) {
|
|
if err := s.EnsureRepository(); 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")
|
|
}
|
|
path, err := s.resolveDeletePath(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = s.EnsureRepository(); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err = os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("auth filestore: delete failed: %w", err)
|
|
}
|
|
if err == nil {
|
|
rel, errRel := s.relativeToRepo(path)
|
|
if errRel != nil {
|
|
return errRel
|
|
}
|
|
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
|
|
}
|
|
if err := s.EnsureRepository(); err != nil {
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if strings.TrimSpace(message) == "" {
|
|
message = "Sync watcher updates"
|
|
}
|
|
return s.commitAndPushLocked(message, filtered...)
|
|
}
|
|
|
|
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)
|
|
}
|
|
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{"path": path},
|
|
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)
|
|
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 (s *GitTokenStore) gitAuth() transport.AuthMethod {
|
|
if s.username == "" && s.password == "" {
|
|
return nil
|
|
}
|
|
user := s.username
|
|
if user == "" {
|
|
user = "git"
|
|
}
|
|
return &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 := repoDir
|
|
if abs, err := filepath.Abs(repoDir); err == nil {
|
|
absRepo = abs
|
|
}
|
|
cleanPath := path
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
cleanPath = abs
|
|
}
|
|
rel, err := filepath.Rel(absRepo, cleanPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("git token store: relative path: %w", err)
|
|
}
|
|
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 transport.AuthMethod) 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 transport.AuthMethod) 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 transport.AuthMethod) error {
|
|
if err := repo.Fetch(&git.FetchOptions{Auth: 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 transport.AuthMethod) (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{Auth: 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 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 transport.AuthMethod) 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 {
|
|
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)
|
|
}
|
|
worktree, err := repo.Worktree()
|
|
if err != nil {
|
|
return fmt.Errorf("git token store: worktree: %w", err)
|
|
}
|
|
added := false
|
|
for _, rel := range relPaths {
|
|
if strings.TrimSpace(rel) == "" {
|
|
continue
|
|
}
|
|
if _, err = worktree.Add(rel); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
if _, errRemove := worktree.Remove(rel); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
|
|
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)
|
|
}
|
|
headRef, errHead := repo.Head()
|
|
if errHead != nil {
|
|
if !errors.Is(errHead, plumbing.ErrReferenceNotFound) {
|
|
return fmt.Errorf("git token store: get head: %w", errHead)
|
|
}
|
|
} else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {
|
|
return errRewrite
|
|
}
|
|
s.maybeRunGC(repo)
|
|
pushOpts := &git.PushOptions{Auth: s.gitAuth(), Force: true}
|
|
if s.branch != "" {
|
|
pushOpts.RefSpecs = []config.RefSpec{config.RefSpec("refs/heads/" + s.branch + ":refs/heads/" + s.branch)}
|
|
} else {
|
|
// When branch is unset, pin push to the currently checked-out branch.
|
|
if headRef, err := repo.Head(); err == nil {
|
|
pushOpts.RefSpecs = []config.RefSpec{config.RefSpec(headRef.Name().String() + ":" + headRef.Name().String())}
|
|
}
|
|
}
|
|
if err = repo.Push(pushOpts); err != nil {
|
|
if errors.Is(err, git.NoErrAlreadyUpToDate) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("git token store: push: %w", err)
|
|
}
|
|
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(repo *git.Repository) {
|
|
now := time.Now()
|
|
if now.Sub(s.lastGC) < gcInterval {
|
|
return
|
|
}
|
|
s.lastGC = now
|
|
|
|
pruneOpts := git.PruneOptions{
|
|
OnlyObjectsOlderThan: now,
|
|
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 {
|
|
if err := s.EnsureRepository(); 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)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
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
|
|
}
|
|
}
|