183 lines
6.1 KiB
Go
183 lines
6.1 KiB
Go
// Package repoclone handles automatic cloning and fetching of git repositories.
|
|
package repoclone
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
)
|
|
|
|
// ghCredentialHelper is the git credential helper command that delegates to gh CLI.
|
|
const (
|
|
ghCredentialHelper = "!gh auth git-credential"
|
|
gitNoTags = "--no-tags"
|
|
)
|
|
|
|
// Config holds configuration for the repository cloner.
|
|
type Config struct {
|
|
// BasePath is the base directory for cloned repos.
|
|
// Supports ~ expansion for home directory.
|
|
// Default: ~/.kandev/repos
|
|
BasePath string `mapstructure:"basePath"`
|
|
}
|
|
|
|
// Cloner handles git clone and fetch operations.
|
|
type Cloner struct {
|
|
config Config
|
|
protocol string
|
|
logger *logger.Logger
|
|
// repoMus is a map of per-repo path → *sync.Mutex to prevent concurrent
|
|
// clone or fetch operations on the same repository directory.
|
|
repoMus sync.Map
|
|
}
|
|
|
|
// NewCloner creates a new Cloner with the given config, git protocol, and data directory.
|
|
// If cfg.BasePath is empty, it defaults to dataDir+"/repos".
|
|
func NewCloner(cfg Config, protocol string, dataDir string, log *logger.Logger) *Cloner {
|
|
if cfg.BasePath == "" && dataDir != "" {
|
|
cfg.BasePath = filepath.Join(dataDir, "repos")
|
|
}
|
|
return &Cloner{config: cfg, protocol: protocol, logger: log}
|
|
}
|
|
|
|
// repoMu returns (or lazily creates) the mutex for a repository path.
|
|
func (c *Cloner) repoMu(path string) *sync.Mutex {
|
|
mu, _ := c.repoMus.LoadOrStore(path, &sync.Mutex{})
|
|
return mu.(*sync.Mutex) //nolint:forcetypeassert // LoadOrStore always stores *sync.Mutex
|
|
}
|
|
|
|
// ExpandedBasePath returns the base path with ~ expanded to the user's home directory.
|
|
func (c *Cloner) ExpandedBasePath() (string, error) {
|
|
path := c.config.BasePath
|
|
if strings.HasPrefix(path, "~/") {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("get home directory: %w", err)
|
|
}
|
|
path = filepath.Join(home, path[2:])
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// BuildCloneURL constructs a protocol-aware clone URL for the given provider/owner/name.
|
|
// This ensures the clone URL matches the user's configured git protocol (SSH vs HTTPS).
|
|
func (c *Cloner) BuildCloneURL(provider, owner, name string) (string, error) {
|
|
return CloneURL(provider, owner, name, c.protocol)
|
|
}
|
|
|
|
// RepoPath returns the full local path for a repository.
|
|
func (c *Cloner) RepoPath(owner, name string) (string, error) {
|
|
basePath, err := c.ExpandedBasePath()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(basePath, owner, name), nil
|
|
}
|
|
|
|
// EnsureCloned clones the repository if it doesn't exist locally, or fetches if it does.
|
|
// The cloneURL is the full git URL (HTTPS or SSH) to clone from.
|
|
// Returns the local filesystem path to the repository.
|
|
// Concurrent calls for the same repository are serialised to prevent double-clone races.
|
|
func (c *Cloner) EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) {
|
|
targetPath, err := c.RepoPath(owner, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
mu := c.repoMu(targetPath)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
gitDir := filepath.Join(targetPath, ".git")
|
|
if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() {
|
|
c.fetch(ctx, targetPath)
|
|
return targetPath, nil
|
|
}
|
|
|
|
return targetPath, c.clone(ctx, cloneURL, targetPath, owner, name)
|
|
}
|
|
|
|
// gitCmd creates a git command with non-interactive environment settings.
|
|
// When the configured protocol is HTTPS, it adds gh CLI as the credential
|
|
// helper so that git can authenticate using the user's gh auth token.
|
|
func (c *Cloner) gitCmd(ctx context.Context, args ...string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, "git", args...)
|
|
env := os.Environ()
|
|
env = append(env, "GIT_TERMINAL_PROMPT=0")
|
|
if c.protocol == ProtocolHTTPS {
|
|
env = append(env,
|
|
"GIT_CONFIG_COUNT=1",
|
|
"GIT_CONFIG_KEY_0=credential.helper",
|
|
"GIT_CONFIG_VALUE_0="+ghCredentialHelper,
|
|
)
|
|
}
|
|
cmd.Env = env
|
|
return cmd
|
|
}
|
|
|
|
func (c *Cloner) fetch(ctx context.Context, repoPath string) {
|
|
c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath))
|
|
cmd := c.gitCmd(ctx, "-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags)
|
|
if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil {
|
|
c.logger.Warn("git fetch failed (non-fatal)",
|
|
zap.String("path", repoPath),
|
|
zap.String("output", string(out)),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath, owner, name string) error {
|
|
parentDir := filepath.Dir(targetPath)
|
|
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
|
return fmt.Errorf("create parent directory: %w", err)
|
|
}
|
|
|
|
c.logger.Info("cloning repository",
|
|
zap.String("url", cloneURL),
|
|
zap.String("target", targetPath))
|
|
|
|
// Try gh repo clone first — handles auth for both SSH and HTTPS.
|
|
if owner != "" && name != "" {
|
|
if err := c.ghClone(ctx, owner, name, targetPath); err == nil {
|
|
return nil
|
|
}
|
|
// Clean up any partial clone so the fallback can retry into a fresh path.
|
|
if rmErr := os.RemoveAll(targetPath); rmErr != nil && !os.IsNotExist(rmErr) {
|
|
return fmt.Errorf("cleanup failed gh clone target: %w", rmErr)
|
|
}
|
|
}
|
|
|
|
// Fallback: git clone with credential helper.
|
|
cmd := c.gitCmd(ctx, "clone", "--filter=blob:none", gitNoTags, cloneURL, targetPath)
|
|
if out, err := subproc.RunGitCombinedOutput(ctx, cmd); err != nil {
|
|
return fmt.Errorf("git clone failed: %s: %w", string(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ghClone attempts to clone using gh repo clone, which handles authentication
|
|
// automatically via the user's gh CLI session.
|
|
func (c *Cloner) ghClone(ctx context.Context, owner, name, targetPath string) error {
|
|
nwo := owner + "/" + name
|
|
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", nwo, targetPath, "--", "--filter=blob:none", gitNoTags)
|
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
|
out, err := subproc.RunGHCombinedOutput(ctx, cmd)
|
|
if err != nil {
|
|
c.logger.Debug("gh repo clone failed, falling back to git clone",
|
|
zap.String("repo", nwo),
|
|
zap.String("output", string(out)),
|
|
zap.Error(err))
|
|
return fmt.Errorf("gh repo clone: %w", err)
|
|
}
|
|
return nil
|
|
}
|