← repoman internals

internal/gitsync

import "git.griefed.de/griefed/repoman/internal/gitsync"
package gitsync // import "git.griefed.de/griefed/repoman/internal/gitsync"

Package gitsync brings a Forgejo repository's git refs up to date with its
source, for repos that are NOT Forgejo pull mirrors.

Why this exists: a one-time Forgejo migration copies git history once and never
refetches it. repoman creates releases itself (Forgejo's bundled importer is
serial, opaque, and skipped entirely by mirrors), and binds each release to
an EXISTING tag. So on every run after the first, a release created upstream
names a tag the Forgejo copy has never seen, and Forgejo rejects it with HTTP
409 "Release has no Tag". Fetching the source's refs and pushing them into
Forgejo closes that gap, so the commits a tag points at exist before the release
referencing it is created.

It also carries refs/notes/*, which is not an optional extra: semantic-release
stores each prerelease tag's release CHANNEL in refs/notes/semantic-release,
and a mirror without those notes cannot tell which channel its prerelease tags
belong to — so a release run on a prerelease branch discards them all and walks
back to the last stable tag. Tags without their notes are an incomplete mirror.

Mirrored repos do not come through here — Forgejo pulls those itself,
and forgejo.Client.MirrorSync asks it to do so now rather than on its interval.
(A pull mirror fetches every ref including notes, so that path needs nothing.)

Like internal/repoarchive this uses the pure-Go go-git library rather than
shelling out to git, keeping repoman a single static CGO-free binary with no
host tooling dependency. go-git resolves transports through a PROCESS-GLOBAL
registry, so the certificate-skipping HTTPS client that repoarchive installs
for the dev-only SkipTLSVerify flag also applies to the pushes made here — no
separate opt-in is needed or wanted.

VARIABLES

var ErrIncompleteOutboundSpec = errors.New(
	"gitsync: both a Forgejo and a target clone URL are required")
    ErrIncompleteOutboundSpec is returned when an OutboundSpec lacks the Forgejo
    or the target clone URL. There is no default for either side of a push.

var ErrIncompleteSpec = errors.New("gitsync: both a source and a Forgejo clone URL are required")
    ErrIncompleteSpec is returned when a Spec lacks a source or target clone
    URL. Both are required; there is no meaningful default for either.


FUNCTIONS

func RemoteBranches(ctx context.Context, cloneURL string) ([]string, error)
    RemoteBranches returns the short branch names a remote advertises, without
    cloning it.

    It is the target half of the push-mirror prune check. An unfiltered Forgejo
    push mirror runs `git push --mirror --force`, which deletes every ref the
    target has and Forgejo does not — so to warn about that, repoman needs the
    target's branch list. A ref advertisement is the cheapest way to get it and
    the only one that works the same on all four platforms: one round trip,
    no objects transferred, no per-platform branches API to implement. The
    caller already builds an authenticated git URL for the target (the same one
    the mirror pushes to), so there is nothing extra to configure.

    Only `refs/heads/*` comes back. A mirror prunes tags and notes too, but the
    warning is about branches — reporting a tag among them would misdescribe
    what is at risk — and the peeled `^{}` entries a server advertises are not
    refs of their own.

    An empty repository is not an error: a target that exists and has never been
    pushed to genuinely has no branches. Anything else IS returned, because the
    caller must not read "could not ask" as "nothing at risk".


TYPES

type DivergedRef struct {
	// Name is the short ref name, e.g. "9.0.0-alpha.1".
	Name string
	// Source is the object the source has for that ref.
	Source string
	// Forgejo is the object Forgejo has for it.
	Forgejo string
}
    DivergedRef is one ref that both sides have, pointing at different objects.
    Both hashes are carried because "they differ" is not actionable on its own:
    an operator needs to see what the forge is holding to decide whether to
    delete it, force it, or leave it.

type OutboundDivergence struct {
	// Name is the short ref name, e.g. "develop" or "1.2.3".
	Name string
	// Forgejo and Target are the objects each side has for it.
	Forgejo string
	Target  string
}
    OutboundDivergence is one ref both sides hold at different objects, where
    the target's object is NOT an ancestor of Forgejo's — so sending it would
    rewrite history the target owns.

    Both objects are carried because "they differ" is not actionable:
    an operator deciding whether to allow forcing needs to see what each side is
    holding.

type OutboundResult struct {
	// Pushed holds the short names of refs created or advanced on the target.
	Pushed []string
	// Pruned holds the short names of refs deleted from the target. Always empty
	// unless AllowPrune was set, and emptied again if the delete push failed —
	// it reports what WAS removed, never what was attempted.
	Pruned []string
	// Diverged names every ref the target holds at an object that is not an
	// ancestor of Forgejo's, and that was therefore skipped because AllowForce was
	// off. This is the only trace those refs leave, so it is never silent.
	Diverged []OutboundDivergence
	// TagsError, HeadsError, NotesError and PruneError hold the per-group failures.
	// They are separate because the groups are pushed separately: one rejected
	// branch must not hide the tags that went through, and a refused deletion (a
	// protected branch on the target) must not hide anything at all.
	TagsError  error
	HeadsError error
	NotesError error
	PruneError error
}
    OutboundResult reports what one push achieved, in enough detail for a run
    log to name what moved and what was deliberately left alone.

func PushOutbound(ctx context.Context, spec OutboundSpec) (OutboundResult, error)
    PushOutbound mirrors a Forgejo repository's refs onto an outbound target
    under an explicit destruction policy, and is the replacement for delegating
    that job to a Forgejo push mirror.

    It fetches Forgejo into a scratch bare repo, reads the target's own ref
    advertisement ONCE, and decides per ref what to do — rather than pushing a
    wildcard refspec and letting the far side reject it. That per-ref decision
    is not a refinement, it is required: go-git validates every ref in a refspec
    BEFORE sending any of them, so a single ref needing a force fails the whole
    group. On this project's own mirror that is exactly how six bad tags stopped
    375 good ones, and the same trap applies in this direction.

    Tags go first because the outbound release publisher gates on the tag
    already being present on the target; then branches, then notes, each in
    its own push so one group's failure cannot hide another's. Deletions,
    if permitted at all, go LAST and alone: a target that refuses one deletion
    (GitLab answers "You can only delete protected branches using the web
    interface") would otherwise take every update down with it.

    The scratch clone is always removed, so disk use is bounded by one repo at a
    time.

type OutboundSpec struct {
	// ForgejoCloneURL is the hub repo to read from. It may embed credentials as
	// userinfo; they are lifted out before the remote is configured.
	ForgejoCloneURL string
	// ForgejoToken authenticates the fetch when ForgejoCloneURL carries no
	// userinfo, using Forgejo's convention of accepting an access token as the
	// basic-auth username — the same arrangement Spec uses, so neither direction
	// has to embed a credential in a URL to say who it is.
	ForgejoToken string
	// TargetCloneURL is the remote repo to push to, likewise possibly authed.
	TargetCloneURL string
	// Label identifies the repo in error messages, e.g. "Griefed/repo".
	Label string
	// MatchBranch decides which branches are in scope, both for pushing and for
	// pruning. Nil means every branch. It is a func rather than a filter string
	// so this package stays free of the config model's glob syntax — the caller
	// owns what a filter MEANS, this owns what a push DOES.
	MatchBranch func(branch string) bool
	// AllowForce permits overwriting a target ref whose object is not an ancestor
	// of Forgejo's. Off, such a ref is reported in Diverged and skipped, and every
	// other ref still goes.
	AllowForce bool
	// AllowPrune permits DELETING refs the target has and Forgejo does not.
	AllowPrune bool
}
    OutboundSpec describes one Forgejo→target ref push performed by repoman
    itself rather than delegated to a Forgejo push mirror.

    It exists because a Forgejo push mirror is `git push -f --mirror` and admits
    no variation: it force-overwrites whatever the target moved on its own and
    deletes every ref the push did not produce. That destroys work created only
    on the target — an update bot's branches, a CI tag, a direct push — and
    the platform then closes every pull request whose head branch vanished.
    Both destructive halves are opt-in here and default off, which is the whole
    point of doing the push in-process.

type Result struct {
	// TagsUpdated is true when the tag push moved at least one ref.
	TagsUpdated bool
	// HeadsUpdated is true when the branch push moved at least one ref.
	HeadsUpdated bool
	// NotesUpdated is true when the notes push moved at least one ref.
	NotesUpdated bool
	// HeadsError holds a non-fatal branch-push failure (typically a diverged
	// branch). Tags may still have been pushed successfully.
	HeadsError error
	// NotesError holds a non-fatal notes-push failure. Notes carry release-channel
	// metadata rather than history, so a rejected notes push must not hide the tags
	// and branches that went through — but it must be reported, because a mirror
	// without them breaks downstream release tooling in a way nothing else reveals.
	NotesError error
	// DivergedTags names every tag Forgejo holds at a different object than the
	// source does. They are skipped rather than pushed — updating one needs a force,
	// and repoman never rewrites a forge's history — so this is the only trace they
	// leave. Empty on a healthy sync.
	DivergedTags []DivergedRef
}
    Result reports what the sync achieved, so the caller can log something
    meaningful and decide whether re-listing tags is worthwhile.

func PushRefs(ctx context.Context, spec Spec) (Result, error)
    PushRefs fetches every ref from the source into a temporary bare repository
    and pushes the tags, then the branches, into Forgejo. It is idempotent:
    an already-current repo pushes nothing and reports no error. The temporary
    clone is always removed, so disk use is bounded by one repository at a time.

type Spec struct {
	SourceCloneURL  string
	ForgejoCloneURL string
	ForgejoToken    string
	// Label identifies the repo in error messages, e.g. "Griefed/repo".
	Label string
}
    Spec describes one source→Forgejo ref sync. SourceCloneURL may embed
    credentials as userinfo (that is how the source clients hand it out);
    ForgejoToken authenticates the push, using Forgejo's convention of accepting
    an access token as the basic-auth username.