package api
import (
"context"
"errors"
"fmt"
"strings"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// GitMirrorStore keeps RFC 0022's mirror setting. The store owns every
// rule that decides whether a mirror may exist (the address, the
// branch, private, takedown), so the ops here only map its refusals.
type GitMirrorStore interface {
GitMirrorOf(repo string) (oltp.GitMirror, bool, error)
SetGitMirror(repo, remote, branch, by, via string) (oltp.GitMirror, error)
DisableGitMirror(repo, by, via string) (bool, error)
}
type gitMirrorIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Remote is an SSH address, such as [email protected]:owner/repo.git.
Remote string `json:"remote" validate:"max=300"`
// Branch is the remote branch trunk is pushed to; empty means main.
Branch string `json:"branch" validate:"max=100"`
}
type gitMirrorRepoIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type gitMirrorOut struct {
Repo string `json:"repo"`
On bool `json:"on"`
// Remote and Branch are where trunk is pushed.
Remote string `json:"remote,omitempty"`
Branch string `json:"branch,omitempty"`
// PublicKey is the line to add to the host as a deploy key with
// write access. The private half never leaves the forge.
PublicKey string `json:"public_key,omitempty"`
// Pushed is the last check-in the host accepted, and when.
Pushed string `json:"pushed,omitempty"`
PushedAt string `json:"pushed_at,omitempty"`
// Error is the last failure in words, and when.
Error string `json:"error,omitempty"`
ErrorAt string `json:"error_at,omitempty"`
// Paused says why the mirror is on but not pushing: the repository
// is private or taken down. Nothing already pushed is withdrawn.
Paused string `json:"paused,omitempty"`
}
func gitMirrorView(m oltp.GitMirror, on bool) gitMirrorOut {
if !on {
return gitMirrorOut{Repo: m.Repo}
}
return gitMirrorOut{Repo: m.Repo, On: true, Remote: m.Remote, Branch: m.Branch,
PublicKey: m.PublicKey, Pushed: m.Pushed, PushedAt: m.PushedAt, Error: m.Err, ErrorAt: m.ErrAt}
}
// gitMirrorErr turns the store's refusals into the API's: a setting it
// will not keep is the caller's to fix, a takedown is not.
func gitMirrorErr(err error) error {
switch {
case errors.Is(err, oltp.ErrMirrorRefused):
return fmt.Errorf("%s: %w", strings.TrimPrefix(err.Error(), oltp.ErrMirrorRefused.Error()+": "), ops.ErrInvalid)
case errors.Is(err, oltp.ErrHeld):
return fmt.Errorf("%v: %w", err, ops.ErrForbidden)
}
return err
}
func (d Deps) registerGitMirror(r *ops.Registry) error {
store := func(ctx context.Context, repo string) error {
if _, err := d.repoFor(ctx, repo); err != nil {
return err
}
if d.GitMirrors == nil {
return fmt.Errorf("this forge keeps no mirror settings: %w", ops.ErrUnavailable)
}
return nil
}
if err := ops.Register(r, ops.Op[gitMirrorRepoIn, gitMirrorOut]{
Name: "repo.gitmirror.get",
Caps: "a",
Doc: "A repository's mirror to a Git host (RFC 0022): the remote, the branch, the deploy key's " +
"public line, the last check-in pushed and the last error.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/git-mirror",
Run: func(ctx context.Context, in gitMirrorRepoIn) (gitMirrorOut, error) {
if err := store(ctx, in.Repo); err != nil {
return gitMirrorOut{}, err
}
m, on, err := d.GitMirrors.GitMirrorOf(in.Repo)
if err != nil {
return gitMirrorOut{}, err
}
m.Repo = in.Repo
out := gitMirrorView(m, on)
if on {
out.Paused = d.gitMirrorPaused(in.Repo)
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[gitMirrorIn, gitMirrorOut]{
Name: "repo.gitmirror.set",
Caps: "a",
Doc: "Mirror a public repository's trunk to a Git host, or change where it is pushed. `remote` is an " +
"SSH address such as [email protected]:owner/repo.git; `branch` defaults to main. The first enable " +
"makes a deploy key and answers its public line, to add to the host with write access; a later " +
"change keeps the key. Refused for a private repository and while a takedown is in force.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/git-mirror",
Run: func(ctx context.Context, in gitMirrorIn) (gitMirrorOut, error) {
if err := store(ctx, in.Repo); err != nil {
return gitMirrorOut{}, err
}
// One direction per repository: one that follows a Git
// upstream is that upstream's copy, and pushing it back out
// would make two sources of record for one history.
if d.Mirrors != nil {
if up, ok, err := d.Mirrors.MirrorFor(in.Repo); err == nil && ok && up.Git && up.Enabled {
return gitMirrorOut{}, fmt.Errorf("%s follows the Git repository %s; mirror that one instead: %w",
in.Repo, up.URL, ops.ErrInvalid)
}
// A repository that broke out of a Git upstream never
// pushes back into it: what it exports is not that
// history, commit for commit, so the push would be
// refused or would rewrite it. The copy goes to a new
// repository (2026-10-29).
if up, ok, err := d.Mirrors.MirrorFor(in.Repo); err == nil && ok && up.DetachedFrom != "" && sameGitRemote(in.Remote, up.DetachedFrom) {
return gitMirrorOut{}, fmt.Errorf("%s is the repository %s broke out of, and its history will never match what is exported from here; mirror to a new repository instead: %w",
up.DetachedFrom, in.Repo, ops.ErrInvalid)
}
}
m, err := d.GitMirrors.SetGitMirror(in.Repo, in.Remote, in.Branch, ops.User(ctx), ops.Via(ctx))
if err != nil {
return gitMirrorOut{}, gitMirrorErr(err)
}
return gitMirrorView(m, true), nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[gitMirrorRepoIn, gitMirrorOut]{
Name: "repo.gitmirror.off",
Caps: "a",
Doc: "Stop mirroring a repository to its Git host and discard the deploy key. Nothing already " +
"pushed is withdrawn; mirroring again makes a new key.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/git-mirror/off",
Run: func(ctx context.Context, in gitMirrorRepoIn) (gitMirrorOut, error) {
if err := store(ctx, in.Repo); err != nil {
return gitMirrorOut{}, err
}
if _, err := d.GitMirrors.DisableGitMirror(in.Repo, ops.User(ctx), ops.Via(ctx)); err != nil {
return gitMirrorOut{}, err
}
return gitMirrorOut{Repo: in.Repo}, nil
},
})
}
// gitMirrorPaused is why an enabled mirror does not push, in words, or
// "" when it does. The same two conditions the push itself checks.
func (d Deps) gitMirrorPaused(repo string) string {
if d.Private != nil && d.Private()[repo] {
return "the repository is private, so the mirror is paused"
}
if d.Takedowns != nil {
if _, held, err := d.Takedowns.TakedownOf(repo); err == nil && held {
return "the repository is taken down, so the mirror is paused"
}
}
return ""
}
// gitMirrorAbout is the About line's host path and web address for a
// mirror that is on and current; empty otherwise. A mirror whose last
// push failed, or that is paused, is not claimed.
func (d Deps) gitMirrorAbout(repo string) (label, link string) {
if d.GitMirrors == nil {
return "", ""
}
m, on, err := d.GitMirrors.GitMirrorOf(repo)
if err != nil || !on || m.Pushed == "" || m.Err != "" || d.gitMirrorPaused(repo) != "" {
return "", ""
}
host, path := gitRemoteHostPath(m.Remote)
if host == "" {
return "", ""
}
return host + "/" + path, "https://" + host + "/" + path
}
// gitRemoteHostPath splits an SSH remote, already checked by
// oltp.CheckGitRemote, into its host and repository path without the
// .git suffix: [email protected]:o/r.git and ssh://[email protected]/o/r.git
// both give github.com and o/r.
func gitRemoteHostPath(remote string) (host, path string) {
if rest, ok := strings.CutPrefix(remote, "ssh://"); ok {
hostPart, p, _ := strings.Cut(rest, "/")
if _, h, ok := strings.Cut(hostPart, "@"); ok {
hostPart = h
}
host, _, _ = strings.Cut(hostPart, ":")
path = p
} else {
_, after, ok := strings.Cut(remote, "@")
if !ok {
return "", ""
}
host, path, _ = strings.Cut(after, ":")
}
path = strings.TrimSuffix(strings.Trim(path, "/"), ".git")
if host == "" || path == "" || strings.HasPrefix(path, "~") {
return "", ""
}
return host, path
}
// sameGitRemote reports whether two spellings name one Git repository:
// [email protected]:owner/repo.git, ssh://[email protected]:22/owner/repo,
// ssh://[email protected]:443/owner/repo and https://github.com/owner/repo
// are the same place. Parsed into host and path rather than rewritten
// as text, so a port, a leading slash or a host's SSH alias does not
// make the old repository look new (jva, ^2610).
func sameGitRemote(a, b string) bool {
ha, pa := gitPlace(a)
hb, pb := gitPlace(b)
return ha != "" && pa != "" && ha == hb && pa == pb
}
// gitHostAliases are the hosts that serve another host's repositories
// over SSH on port 443.
var gitHostAliases = map[string]string{
"ssh.github.com": "github.com",
"www.github.com": "github.com",
"altssh.gitlab.com": "gitlab.com",
"altssh.bitbucket.org": "bitbucket.org",
}
// gitPlace is a remote's host, without user or port and through
// gitHostAliases, and its path, without slashes at either end or a
// .git suffix, both lower-cased. Empty for something that names no
// host.
func gitPlace(remote string) (host, path string) {
s := strings.ToLower(strings.TrimSpace(remote))
scheme := false
for _, p := range []string{"https://", "http://", "ssh://", "git+ssh://", "git://"} {
if rest, ok := strings.CutPrefix(s, p); ok {
s, scheme = rest, true
break
}
}
if scheme {
// host[:port]/path, with an optional user@ in front.
host, path, _ = strings.Cut(s, "/")
if _, h, ok := strings.Cut(host, "@"); ok {
host = h
}
host, _, _ = strings.Cut(host, ":")
} else {
// user@host:path, the scp form.
if at := strings.Index(s, "@"); at >= 0 && at < strings.IndexAny(s+":", ":") {
s = s[at+1:]
}
host, path, _ = strings.Cut(s, ":")
}
if alias, ok := gitHostAliases[host]; ok {
host = alias
}
path = strings.Trim(path, "/")
for strings.Contains(path, "//") {
path = strings.ReplaceAll(path, "//", "/")
}
path = strings.TrimSuffix(path, ".git")
return host, path
}