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.