package common import ( "errors" "fmt" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5" gitconf "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" ) // GitWorkspace is an in-memory git working tree for a single domain's // certificate repository. It is the shared primitive that both client and // server modes operate on: client mode clones and reads, server mode // init/clones and pushes. The struct carries no mode-specific state. type GitWorkspace struct { Domain string URL string Storage *memory.Storage FS billy.Filesystem Repo *git.Repository WorkTree *git.Worktree } // GitSource identifies a supported git repository host. type GitSource int const ( Github GitSource = iota Gitlab Gitea Gogs Bitbucket CodeCommit ) // GitSourceName maps GitSource to the string used in the app config's // git.host field. var GitSourceName = map[GitSource]string{ Github: "github", Gitlab: "gitlab", Gitea: "gitea", Gogs: "gogs", Bitbucket: "bitbucket", CodeCommit: "code-commit", } // StrToGitSource parses a config string (e.g. "gitea") into a GitSource. func StrToGitSource(s string) (GitSource, error) { for k, v := range GitSourceName { if v == s { return k, nil } } return 0, fmt.Errorf("invalid git source %q", s) } // ErrRepoNotFound is returned by RepoProvider implementations when a domain's // repository does not exist on the remote. Callers use it to distinguish // "repo hasn't been created yet" from transport or auth failures. var ErrRepoNotFound = errors.New("repository not found") // NewGitWorkspace returns a workspace with an in-memory filesystem and storage // wired up. Call InitRepo (for a brand-new remote) or CloneRepo (for an // existing one) to populate Repo and WorkTree. func NewGitWorkspace(domain, url string) *GitWorkspace { return &GitWorkspace{ Domain: domain, URL: url, Storage: memory.NewStorage(), FS: memfs.New(), } } // RepoURL builds the canonical clone URL for a domain's certificate repo. It // is the single authoritative place for the "//.git" // pattern so callers do not assemble URLs by hand. func RepoURL(config *AppConfig, domainConfig *DomainConfig, domain string) string { return config.Git.Server + "/" + config.Git.OrgName + "/" + domain + domainConfig.Repo.RepoSuffix + ".git" } // InitRepo initializes an empty local repository in ws and registers origin // pointed at ws.URL. Use this on the first push for a new domain; use // CloneRepo on subsequent runs. func InitRepo(ws *GitWorkspace) error { repo, err := git.Init(ws.Storage, ws.FS) if err != nil { return fmt.Errorf("git init: %w", err) } if _, err := repo.CreateRemote(&gitconf.RemoteConfig{ Name: "origin", URLs: []string{ws.URL}, }); err != nil && !errors.Is(err, git.ErrRemoteExists) { return fmt.Errorf("add remote: %w", err) } wt, err := repo.Worktree() if err != nil { return fmt.Errorf("get worktree: %w", err) } ws.Repo = repo ws.WorkTree = wt return nil } // CloneRepo clones ws.URL into ws using the git credentials from config. It // performs no ownership or mode-specific checks: server mode must follow up // with server.VerifyOwnership before pushing. func CloneRepo(ws *GitWorkspace, config *AppConfig) error { auth := &http.BasicAuth{ Username: config.Git.Username, Password: config.Git.APIToken, } repo, err := git.Clone(ws.Storage, ws.FS, &git.CloneOptions{URL: ws.URL, Auth: auth}) if err != nil { return fmt.Errorf("git clone %s: %w", ws.URL, err) } wt, err := repo.Worktree() if err != nil { return fmt.Errorf("get worktree: %w", err) } ws.Repo = repo ws.WorkTree = wt return nil }