Multi-provider, bidirectional Git repository mirroring tool.
Supports CodeCommit, GitLab, GitHub with any-to-any mirroring via SQS polling and webhook receivers.
Event Sources Git-Bridge Targets
┌──────────────────────┐ ┌──────────────────┐
│ │ │ │
┌──────────────┐ │ EventBridge → SQS │──(poll)──▶ │ │──▶ GitLab
│ CodeCommit │──▶│ (referenceUpdated) │ │ │
└──────────────┘ │ + DLQ │ │ │──▶ GitHub
└──────────────────────┘ │ Mirror Svc │
│ │──▶ CodeCommit
┌──────────────┐ ┌──────────────────────┐ │ clone → push │
│ GitLab │──▶│ POST /webhook/gitlab│──(http)──▶ │ │──▶ ...
└──────────────┘ └──────────────────────┘ │ │
│ │
┌──────────────┐ ┌──────────────────────┐ │ │ ┌───────┐
│ GitHub │──▶│ POST /webhook/github│──(http)──▶ │ │──────▶│ Slack │
└──────────────┘ └──────────────────────┘ │ │ └───────┘
└──────────────────┘
| Source Provider | Event Delivery | Trigger |
|---|---|---|
| CodeCommit | EventBridge → SQS → long-polling | referenceUpdated event |
| GitLab | Push Webhook → POST /webhook/gitlab |
Push event |
| GitHub | Push Webhook → POST /webhook/github |
Push event |
- Multi-provider: CodeCommit, GitLab, GitHub (extensible via
Providerinterface) - Any-to-any: Any provider can mirror to any other provider
- Multi-repo: Configure multiple repositories in a single instance
- Bidirectional:
source-to-target/target-to-source/bidirectional - Delete propagation: A branch/tag deletion on one side propagates to the other (CodeCommit ↔ GitLab/GitHub). Idempotent handling breaks the echo-delete loop.
- Ref restore: Because a delete records the tip it removed, the console can put that ref back with one click. It refuses rather than overwrite if the ref returned in the meantime.
- Rewind guard: Before pushing, each ref is compared against the destination's current tip. A push that would move the destination backwards — because it already contains what this side holds — is withheld rather than forced, and the write itself carries a
--force-with-leaseagainst the tip it was checked against. A deliberate rewind is applied on request (force, see Retry API). - Loop detection: Skips notification on no-op push (already up-to-date), preventing redundant alerts in bidirectional sync
- Multi-SQS consumer: Support multiple SQS queues for multi-AWS region/account environments
- Dual event sources: SQS polling (CodeCommit) + HTTP webhooks (GitLab/GitHub)
- DLQ support: Failed SQS messages retry up to 5 times, then move to DLQ
- Notifications: Slack webhook on success/failure with commit author, branch, and tag info (see Slack App Setup)
- Incremental sync: Reuses existing mirror via
git fetch— full clone only on first run or fallback - Persistent cache: PVC-backed mirror directory survives pod restarts for fast recovery
- Cloud-native: K8s Deployment with liveness/readiness probes
- Language: Go 1.26+
- AWS SDK: aws-sdk-go-v2 (SQS consumer)
- Git: Incremental
git fetch --prune(withgit clone --mirrorfallback) /git pushwith a per-ref--force-with-lease - Config: YAML with
${ENV_VAR}expansion (credentials only; repos defined directly) - CI/CD: GitHub Actions (test, release, changelog)
- Runtime: Kubernetes (Alpine-based Docker image)
git-bridge/
├── cmd/git-bridge/ # Entry point
├── internal/
│ ├── config/ # YAML config with env var expansion
│ ├── consumer/
│ │ ├── sqs.go # SQS consumer (CodeCommit events via EventBridge)
│ │ └── webhook.go # HTTP webhook consumer (GitLab/GitHub push events)
│ ├── mirror/ # Git mirror operations (incremental fetch/clone, push, direction-aware)
│ ├── provider/ # Git provider abstraction (CodeCommit, GitLab, GitHub)
│ ├── notify/ # Slack webhook notifications
│ └── server/ # HTTP server (health + webhook endpoints)
├── k8s/ # Kubernetes manifests (production, minimal comments)
│ ├── namespace.yaml
│ ├── secret.yaml # Credentials only (tokens, keys, passwords)
│ ├── configmap.yaml # config.yaml (repos defined directly, credentials via ${ENV_VAR})
│ ├── pvc.yaml # PersistentVolumeClaim for mirror cache (optional)
│ └── deployment.yaml # Deployment + Service + Ingress
├── examples/ # Example files with detailed comments
│ ├── config.yaml # App config example
│ ├── secret.yaml # K8s Secret example (placeholder values)
│ ├── configmap.yaml # K8s ConfigMap example
│ └── deployment.yaml # K8s Deployment + Service + Ingress example
├── .github/workflows/ # GitHub Actions (test, release, changelog, etc.)
├── cliff.toml # git-cliff changelog configuration
├── Makefile # Build, test, deploy commands
└── Dockerfile # Multi-stage build (golang → alpine)
Credentials are injected via environment variables (${VAR} syntax, expanded at startup). Repository definitions are written directly in the ConfigMap — no env vars needed for repos.
Provider and consumer env vars follow the
<TYPE>_<NAME>_<FIELD>pattern. The service-wide ones (WEBHOOK_*_SECRET,RETRY_API_TOKEN,SLACK_WEBHOOK_URL,CONFIG_PATH,WORK_DIR) and the per-repo override (<REPO>_SLACK_WEBHOOK_URL) sit outside it. See docs/naming-convention.md for the full naming convention guide.Example files with detailed comments are available in the examples/ directory. Use them as a starting point for your own configuration.
| Variable | Description | Required |
|---|---|---|
CODECOMMIT_<NAME>_REGION |
AWS region per CodeCommit provider (e.g. CODECOMMIT_EU_REGION) |
Yes* |
CODECOMMIT_<NAME>_GIT_USERNAME |
CodeCommit HTTPS Git username (e.g. CODECOMMIT_EU_GIT_USERNAME) |
Yes* |
CODECOMMIT_<NAME>_GIT_PASSWORD |
CodeCommit HTTPS Git password (e.g. CODECOMMIT_EU_GIT_PASSWORD) |
Yes* |
GITLAB_<NAME>_BASE_URL |
GitLab instance URL (e.g. GITLAB_MAIN_BASE_URL) |
Yes* |
GITLAB_<NAME>_TOKEN |
GitLab personal access token (e.g. GITLAB_MAIN_TOKEN) |
Yes* |
GITHUB_<NAME>_TOKEN |
GitHub personal access token (e.g. GITHUB_MAIN_TOKEN) |
Yes* |
SQS_<NAME>_QUEUE_URL |
SQS queue URL per consumer (e.g. SQS_EU_QUEUE_URL) |
Yes** |
SQS_<NAME>_REGION |
SQS region per consumer (e.g. SQS_EU_REGION) |
Yes** |
SQS_<NAME>_ACCESS_KEY |
AWS access key per consumer (e.g. SQS_EU_ACCESS_KEY) |
Yes** |
SQS_<NAME>_SECRET_KEY |
AWS secret key per consumer (e.g. SQS_EU_SECRET_KEY) |
Yes** |
WEBHOOK_GITLAB_SECRET |
X-Gitlab-Token verification (empty = skip) | No |
WEBHOOK_GITHUB_SECRET |
GitHub webhook secret for HMAC-SHA256 (empty = skip) | No |
RETRY_API_TOKEN |
Bearer token for /retry/mirror. Empty disables the endpoint entirely (404), and a scheduled reconcile calls it with this token too — so it is effectively required |
Yes |
SLACK_WEBHOOK_URL |
Slack incoming webhook URL (empty = disabled) | No |
<REPO>_SLACK_WEBHOOK_URL |
Per-repo Slack channel override, referenced as ${...} from repos[].slack_webhook_url (e.g. DEMO_REPO_SLACK_WEBHOOK_URL). Empty falls back to SLACK_WEBHOOK_URL |
No |
CONFIG_PATH |
Config file path (default: /etc/git-bridge/config.yaml) |
No |
WORK_DIR |
Temp directory for git operations (default: /tmp/git-bridge) |
No |
* Required per provider. Follow the
<TYPE>_<NAME>_<FIELD>pattern.<NAME>is a free-form identifier — e.g.EU/USfor AWS services,MAIN/SECONDARYfor platform services. ** Required per SQS consumer. Follow theSQS_<NAME>_*pattern — e.g.SQS_EU_*,SQS_US_*,SQS_AP_*
Repos are defined directly in k8s/configmap.yaml under the repos: section. No environment variables or Secret changes needed — just add a new entry:
repos:
- name: my-new-repo
source: codecommit-eu
target: gitlab-main
source_path: my-new-repo
target_path: server/my-new-repo
direction: source-to-target| Direction | Description | Trigger | Example |
|---|---|---|---|
source-to-target |
Source → Target only | SQS (CodeCommit) or source webhook | CodeCommit → GitLab |
target-to-source |
Target → Source only | Target provider webhook required | GitLab → CodeCommit |
bidirectional |
Both directions | SQS + target webhook both required | CodeCommit ↔ GitLab |
Note:
target-to-sourceandbidirectionalrequire webhook configuration on the target provider (GitLab/GitHub). If using onlysource-to-targetwith CodeCommit as source, SQS (EventBridge) triggers automatically — no webhook setup needed.See docs/ADVANCE.md for all provider combinations and detailed configuration examples.
Deleting a branch/tag on one side deletes it on the other. The mirror would otherwise propagate only pushes and leave deletes behind, accumulating orphan refs on one side.
- CodeCommit → target: EventBridge
referenceDeleted→ SQS → ref deleted on the target (GitLab/GitHub). - target → CodeCommit: GitLab/GitHub have no dedicated delete webhook event, so the delete is detected from the push payload — GitLab sends a zero-SHA
after, GitHub sendsdeleted: true. No extra webhook configuration is needed (the push events you already receive are enough). - Idempotent handling: Before deleting,
git ls-remotereads the ref's tip on the destination; if the ref is already gone, the operation ends as a successful no-op. This auto-terminates the bidirectional delete loop ("delete A → delete B → B's delete event echoes back to A") on one leg. - The discarded tip is recorded: that same
ls-remotereturns the SHA the ref pointed at, and it is written to the history event (deleted_tip) and the Slack message before the delete runs. A delete is the one operation that leaves nothing behind to look up — afterwards the destination names neither the ref nor the commit — so this is the only surviving handle on what was removed. The console shows it with agit fetch <clone-url> <sha>recovery line, the same way it does for an overwritten tip. git keeps the objects until it garbage-collects, so the window is real but not indefinite. - A recorded tip can be put back: because
deleted_tipsurvives, the console offers a restore button on that row instead of only printing the two commands to run by hand. The restore only ever fills a hole it can still see — if the ref is back on the destination it refuses (ref-exists) rather than overwrite, and if git has already collected the commit it fails asobject-gone. Once the ref is re-created the mirror propagates it to the other side like any other push. See Console. - There is deliberately no equivalent for a forced update. There the ref still exists and points at something newer, so pushing the old tip back would destroy whatever legitimately landed in the meantime. A delete leaves a hole that can be filled; an overwrite leaves a decision, which is why that case still offers the
git fetchalone. - When a
ref_overridesentry matches, a delete propagates only in the allowed direction (just like a push); a reverse-direction delete is silently skipped — protecting the authoritative side.
In a bidirectional repo you can pin specific refs (branches/tags) to a single direction. When one side is the clear authority for a branch, this structurally prevents the other side's stale push or accidental delete from overwriting the authoritative copy. The repo as a whole stays bidirectional.
repos:
- name: my-repo
source: codecommit-eu
target: gitlab-main
direction: bidirectional # repo stays bidirectional
ref_overrides:
- { pattern: "release", from: gitlab-main, to: codecommit-eu }
- { pattern: "release-*", from: gitlab-main, to: codecommit-eu }pattern: ref short-name glob (path.Match).*does not cross/(release/*matches onlyrelease/x).from/to: the allowed direction's source/destination provider names. (Provider names are used directly instead of source/target labels to avoid direction confusion.)
Behavior:
- For a matched ref, events in the opposite direction (push and delete) are silently skipped (the SQS message is still deleted, so no retries / DLQ).
- A push is scoped to the ref the event named. When an event carries a ref (
meta.Ref != ""), only that single ref is pushed — whether or not the repo declaresref_overrides. If that ref does not exist locally the push is skipped asno-refs-to-pushrather than failing (this guards a retry for a branch that was deleted, and a fetch↔push prune race). - An event with no ref (a full sync, or the hourly reconcile cron) pushes everything (
--all) for a repo withoutref_overrides, and every local ref minus the ones excluded for this direction for a repo with them. - If ref enumeration (
ListRefs) fails the sync ends as an error — it does not fall back to pushing everything.--allcannot carry a per-ref lease, so the rewind guard would drop out entirely; a skipped sync is recovered by the next event or the hourly reconcile.
Why this is not gated on
ref_overrides: gating it that way destroyed a commit on 2026-08-10. Ademo-repoevent forversion/4.2.0pushed every ref because the repo declares no overrides, and it force-wrotemaster-bfrom a source that had not yet seen a commit pushed there 49 seconds earlier. An event names the ref it is about; pushing anything else is the mirror acting on state it was not told about. Refs that never get their own event are still reconciled by the hourly cron, which sends no ref and therefore still pushes everything.
Validation rules:
from/tomust be this repo's source and target, and must differ. For a one-way repo the pinned direction must match the repo direction, and duplicatepatterns are rejected.
| Path | Method | Description |
|---|---|---|
/health |
GET | Liveness probe |
/ready |
GET | Readiness probe |
/api-docs |
GET | Swagger UI (API docs) |
/openapi.json |
GET | OpenAPI spec (kept in sync with routes by a unit test) |
/webhook/gitlab |
POST | GitLab push event receiver |
/webhook/github |
POST | GitHub push event receiver |
/retry/mirror |
POST | Manual mirror retry (requires Authorization: Bearer <RETRY_API_TOKEN>) |
See docs/API.md for detailed request/response specifications.
The console is served on its own listener (server.console_port, default 8081), never on the public port. The public route only forwards to server.port, so the console is unreachable from outside the cluster and only a reverse-proxy portal attaches to this port.
The two ports use separate muxes. The console handlers are simply not registered on the public mux, and that is the entire guard — which is why nothing a client can forge, such as a header or the Host value, takes part in the decision: the socket that accepted the connection is the only thing that decides. On the public port the paths below answer 404, not 403, because a public caller has no need to learn that the console exists.
| Path | Method | Description |
|---|---|---|
/ |
GET | Console page (recent mirror activity) |
/console/api/history |
GET | Recent events as JSON (limit, failures=true, forced=true, repo=<name>, source=<trigger>, hide_routine=true) |
/console/api/retry |
POST | Re-sync one repository ({"repo": "...", "to": "..."}). to is the destination endpoint of the row being re-run; the server turns that side into the direction that writes it. An explicit direction beats it, and with neither the request falls back to auto. 409 when to is not a side of that repo, or names a direction the repo's direction forbids |
/console/api/restore |
POST | Re-create a ref a delete removed ({"repo": "...", "to": "...", "ref": "refs/heads/x", "sha": "<40-char>"}). Synchronous; 409 when refused |
/console/api/force |
POST | Apply a rewind the push guard withheld ({"repo": "...", "to": "...", "ref": "refs/heads/x", "dest": "<40-char>"}). dest is the tip you are overwriting and becomes the push's lease; 409 when no matching hold is recorded |
/console/api/me |
GET | The viewer the portal authenticated, plus the docs link and which write routes are wired in (user / name / email / groups / api_docs_url / restore_enabled / force_enabled) |
console_portand the deployment'scontainerPort/ Service port have to move together. Changing only one either closes the console or opens it on the public port.
🛑 The console has no authentication of its own. It trusts the
X-Auth-*headers a front proxy sets, and is safe only because nothing but that proxy can reach its port. The shipped manifests therefore expose onlyserver.port; do not add a Service orcontainerPortforconsole_portunless an authenticating proxy sits in front of it, or you will publish an unauthenticated page carrying retry, restore and force-push buttons. To look at it locally, port-forward instead:kubectl -n git-bridge port-forward deploy/git-bridge 8081:8081 # then open http://localhost:8081/
What the console does:
- Filter by repository, failures only, row count — the repository list in the dropdown comes from the history, so it never offers an option that returns nothing.
- Expandable rows — clicking a row shows the route, ref and duration along with the full stderr of the git command that failed.
- Echo collapsing — one real push always produces two events: the sync, and the echo from the other side. Events sharing a repository, ref and action within a two-minute window collapse into
+N echo. - Mirror-loop detection — three or more pushes that actually moved the same ref inside that window highlight the group and are counted at the top. An echo that does not settle after one round is what a loop looks like. Only
okcounts: every real push already produces oneokplus askipecho reporting there was nothing left to do, so counting the skip would flag two ordinary pushes to the same branch — and that skip is the evidence the echo terminated, not that it looped. Deliberate triggers (cron reconcile, manual retry) are excluded for the same reason. - Forced-update detection — a push that moves a ref non-fast-forward discards commits at the destination. Before alerting, each overwritten branch is checked with a byte-exact patch-id comparison (
git patch-id --verbatim): if the change of every commit only the old tip reached is present byte for byte in the new history — a rebase, or an amend — the overwrite is recorded asrewrittenand shown in amber as content preserved. Anything else is shown in red, and expanding it shows the discarded tip, the commits that have no equivalent (author and subject), and the command to recover each tip. The check errs toward red: a merge commit has no patch-id, a rebase that resolved a conflict changes that commit's patch-id, a whitespace-only difference counts as a difference, and a check that fails to run, or that would span too many commits to finish inside the push timeout, is treated as a loss. Only content is compared, not commit messages. The push itself succeeded, so the result staysokand the failures filter never surfaces it — hence a separate forced updates only filter, which lists both colours. Slack is alerted only when a branch may have lost commits; a preserved rewrite is named in the ordinary success message instead, and a tag is recorded but stays quiet, because a pipeline that reuses build tag names re-points them constantly and an alert that fires on routine traffic is one people learn to ignore. - Re-run a sync — expanding a row offers a button that re-syncs that repository after a confirmation. Useful for recovering from a failure, and for making one repository catch up without waiting for the hourly reconcile. The button re-runs the direction that row records, because it sends the row's destination endpoint along and the server resolves that side into a direction. It used to send
auto, which on a bidirectional repo always resolves throughretry_direction(target-to-source) — so clicking it on a row that failedsource-to-targetre-ran the other leg, whose destination was already ahead and could therefore only skip, leaving the real gap until the hourly reconcile. The label and the confirmation both name the side being written. Inside a collapsed group the button follows the failed event, not the head: the head is the echo coming back the other way, so following it would re-run the leg that already worked. - Restore a deleted ref — a delete row that recorded a
deleted_tipcarries a red Restore this ref button, which re-creates the ref at that tip after a confirmation naming the repository, the destination and the commit. It is a separate button from retry, and red, because this one writes to a repository rather than re-running a sync. The restore is attributed: the portal'sX-Auth-Userbecomes the event'sactorand appears in the Slack message. Restoring propagates — the destination's own push event then carries the ref to the other side. - Apply a withheld rewind — a row the push guard withheld lists each held ref with the destination tip that stopped the write, and carries a red Apply rewind of
<ref>button per ref. The confirmation names the commit that will be discarded rather than asking "are you sure", because that commit is the decision. The server re-checks the hold against the history before acting, so a press arriving after the two sides converged on their own is refused (409) instead of overwriting something. Attributed the same way a restore is. One button moves one ref — a force is never repo-wide. - Who is viewing · API docs — the header carries a
Hello, <name>greeting and anAPI docslink. The wording is meant to match the portal a reader clicks through from: being addressed two different ways on two consecutive screens reads as two different systems. The display name falls backname → user → email, since an SSO account with no first/last name has an empty display name. The values come from theX-Auth-User/X-Auth-Name/X-Auth-Email/X-Auth-Groupsheaders it sets when proxying. What makes those trustworthy is the listener they arrive on: only the portal reaches the console port, and the portal overwrites any header a client sent under those names. Reached without the portal — a port-forward while debugging — the values are empty and the label stays hidden.
🔑 Retrying never puts an API token in the browser. The console asks the server, and the server calls the mirror service itself.
RETRY_API_TOKENstays inside the pod, and the portal session (login plus group check) is the only credential involved. Such a retry is recorded assource: console, which distinguishes it from the hourly reconcile (cron).
🛑 A restore never overwrites. The server re-reads the destination with
ls-remoteand refuses (409, reasonref-exists) if the ref came back, because between the row being rendered and the button being pressed someone may have re-created that branch — and overwriting it would be the same accident the feature exists to undo, with a different victim. The push that follows uses--force-with-lease=<ref>:(an empty expect means "this ref must not exist"), so even the window between the check and the write is closed by the remote atomically. A plain non-force push was not enough: non-force only rejects non-fast-forward updates, so a branch someone re-created at an ancestor of the restored commit would have been silently advanced onto it. A restore also passes the same gates every other write does — it is refused for a side the repo'sdirectionnever writes to, or a direction aref_overridepins away from — and it proceeds only when the history still records that delete (no-matching-delete). Restoring the same tip twice is a no-op skip, not an error, and a commit git has already garbage-collected fails asobject-gone— the mirror cache is tried first, then a direct fetch from the other side. Unlike retry, which answers202and reports later, the route is synchronous: the refusal is the interesting outcome and has to reach the person who clicked.
🛑 A restore does not queue behind a mirror. It is the only mirror operation that runs inside an HTTP request, so waiting for the lock would hold the connection for the length of whatever fetch is in flight. A held lock is refused immediately as
503 repo-busy; pressing the button again once the sync lands is the whole recovery. Every response also carries a machine-readablereasonbeside the humanerror, so a refusal (the guard worked — go look at what changed on the destination) reads differently from a breakage (the service could not do its job) without parsing prose.
Every completed mirror operation is appended as one line to a JSONL file on the volume. The mirror cache is disposable — it can always be re-cloned — and the history is not, so the two live in separate directories.
| Item | Value |
|---|---|
| Location | <work-dir>/.history/events.jsonl (default /tmp/git-bridge/.history/) |
| Format | One JSONL line per event |
| Rotation | Rotated to .1 past 10MB, keeping two generations (20MB on disk at most) |
| In memory | The newest 5000 events in a ring buffer — the console reads that, the file stays the record. The hourly reconcile is the noisiest producer, so a 500-entry ring was spent in five days (a webhook failure from last week had already been pushed out by routine no-ops); 5000 restores a ~50 day window |
| Restart | The tail of the file is re-read at startup to refill the ring buffer |
One event looks like this:
{
"ts": "2026-07-28T04:12:33Z",
"repo": "test-repo",
"action": "mirror",
"source": "webhook",
"from": "gitlab/team/test-repo",
"to": "codecommit/test-repo",
"ref": "refs/tags/v1.0.0",
"result": "skip",
"reason": "already-up-to-date",
"duration_ms": 812
}action—mirror(branch/tag sync),delete(ref delete propagation) orrestore(a console click putting back what a delete removed). A restore is its own action rather than a mirror because nothing upstream asked for it — a person did.source—webhook/sqs/cron(the reconcile CronJob) /retry-api(a hand-run call) /console.result—ok/skip/fail. Failures also carryerr.reason— narrowsresult. A skip is not one thing (already-up-to-date/ref-override/no-refs-to-push/already-absent), and without this field those are indistinguishable in the log. It also narrows a success:forced-updatemeans the push itself worked, but at least one ref was overwritten non-fast-forward and may have taken commits with it, so whatever lived only on the tip it replaced is gone from the destination;rewrittenmeans every overwritten ref was checked and each discarded commit's change is present byte for byte in the new history. Each entry inforcedcarriespreserved, and an unpreserved one lists what it lost inlost(capped) with the full count inlost_total. A refused restore names why:ref-exists(the ref came back, so re-creating it would overwrite whoever put it there) orobject-gone(git collected the commit);create-refis a restore that failed at the push itself.deleted_tip— on adeletethat actually removed something, the SHA the ref pointed at, read just before the delete ran. Absent everywhere else, including a delete that found the ref already gone. It exists because a delete leaves nothing behind to look up, so this is the only record of what was discarded.restored_tip— on arestorethat actually re-created the ref, the SHA it was put back at. The counterpart todeleted_tip: the two events together tell the whole story of a ref that went away and came back, without anyone having to correlate them by timestamp.actor— who a console-driven action is attributed to, read from the portal'sX-Auth-Userheader. Set only for actions a person triggers, because those are the only ones with a person behind them — a webhook or an SQS event has a pusher, not an operator. A restore writes to a real repository, so "who did this" has to survive in the record rather than only in whoever happened to be watching the channel.duration_ms— includes waiting for the per-repo lock. The duration in the Slack message starts after the lock and differs on purpose: Slack answers "how long did this sync take", the history answers "how long did this event take to be dealt with".
Recording history never fails a mirror operation. By the time a line is written the mirror has already succeeded or failed on its own, and losing an audit line is better than turning a sync that worked into a failure.
# Build binary (ldflags inject version/commit/build-date)
make build
# Print version
./bin/git-bridge -version
# Run tests with race detection + coverage
make test
make cover # HTML coverage report
# Format, vet, lint
make fmt
make vet
make lint # auto-downloads golangci-lint to ./bin/
make lint-fix
# Docker build (multi-arch via buildx)
make docker-build
make docker-buildx # build + push linux/amd64,linux/arm64
# Cross-compile binaries for linux/darwin × amd64/arm64 → dist/
make cross-build# Show current version across all files
make version
# Bump version across Makefile, Helm Chart.yaml, values.yaml, k8s/deployment.yaml, README.md
make bump-version VERSION=v0.2.0# 1. Create namespace
kubectl apply -f k8s/namespace.yaml
# 2. Create secrets (edit secret.yaml values first!)
kubectl apply -f k8s/secret.yaml
# 3. Create PVC (optional), configmap and deployment
kubectl apply -f k8s/pvc.yaml # optional: for persistent mirror cache
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/deployment.yaml
# Verify
kubectl get pods -n git-bridge
kubectl logs -n git-bridge -l app=git-bridge -fOr use the Makefile shortcut:
make deploy-k8s # apply all manifests + wait for rollout
make undeploy-k8s # remove all manifestsA Helm chart is available under helm/git-bridge with examples for common scenarios.
Recommended: OCI registry (Helm 3.8+)
helm install git-bridge oci://ghcr.io/somaz94/charts/git-bridge \
--version 0.4.0 \
--namespace git-bridge --create-namespace \
-f helm/git-bridge/examples/default.yamlAlternative: classic Helm repo
helm repo add git-bridge https://somaz94.github.io/git-bridge/helm-repo
helm repo update
helm install git-bridge git-bridge/git-bridge \
--namespace git-bridge --create-namespace \
-f helm/git-bridge/examples/default.yamlLocal chart (development)
helm install git-bridge ./helm/git-bridge \
--namespace git-bridge --create-namespace \
-f helm/git-bridge/examples/default.yaml
# Lint + template-test
make test-helmAvailable examples:
examples/default.yaml— CodeCommit → GitLab mirror with managed secretexamples/webhook-only.yaml— GitHub ↔ GitLab bidirectional via webhooksexamples/codecommit-multi-region.yaml— multi-region CodeCommit with external secret
make deploy # build + run ./bin/git-bridge with examples/config.yaml
make deploy-smoke # run hack/test-deploy.sh against localhost:8080
make deploy-all # deploy + smoke
make undeploy
make deploy-docker # run the Docker image
make undeploy-dockermake restart # Restart Kubernetes deployment
make logs # Tail pod logs
make help # Show all Makefile targetsWebhook setup is required when direction is
target-to-sourceorbidirectional. If using onlysource-to-targetwith CodeCommit as source, SQS triggers automatically — no webhook needed.
Configure individually for each target GitLab project. See docs/gitlab-webhook-setup.md for detailed setup guide.
- Go to GitLab project > Settings > Webhooks
- URL:
http://git-bridge.example.com/webhook/gitlab - Secret token: (match
WEBHOOK_GITLAB_SECRET) - Trigger: Push events
- Enable SSL verification: No (HTTP)
Configure individually for each target GitHub repository. See docs/github-webhook-setup.md for detailed setup guide.
- Go to GitHub repo > Settings > Webhooks > Add webhook
- Payload URL:
http://git-bridge.example.com/webhook/github - Content type:
application/json - Secret: (match
WEBHOOK_GITHUB_SECRET) - Events: Just the push event
- Add the repo entry to
k8s/configmap.yamlunderrepos::
- name: new-repo
source: codecommit-eu
target: gitlab-main
source_path: new-repo
target_path: server/new-repo
direction: source-to-target-
If using CodeCommit with EventBridge → SQS, add the repo name to your Terraform configuration.
-
Apply and restart:
kubectl apply -f k8s/configmap.yaml
kubectl rollout restart -n git-bridge deployment/git-bridgeNo changes to
secret.yamlordeployment.yamlare needed. If direction istarget-to-sourceorbidirectional, webhook setup is also required on the target provider (GitLab/GitHub) project. See Setting Up Webhooks.
Implement the Provider interface in internal/provider/:
type Provider interface {
CloneURL(repoPath string) string
Type() string
}Register it in provider.New().
| Document | Description |
|---|---|
| Development | Build, test, lint, Docker, Helm, local/K8s deploy, CI workflows |
| Version Management | Version locations, bump flow, release process |
| Naming Convention | Multi-provider naming convention guide |
| Advanced Config | All provider combinations and detailed examples |
| GitLab-to-GitLab Mirror | Mirroring between two GitLab instances — config shape, constraints, verification |
| API Reference | Endpoint request/response specifications |
| Retry API Guide | POST /retry/mirror usage — token extraction, direction options, scenarios |
| Mirror Retry | Manual retry procedure for failed mirrors + retry API background / incident case |
| GitLab Webhook | GitLab webhook setup guide |
| GitHub Webhook | GitHub webhook setup guide |
| Slack App Setup | Slack notification setup guide |
Issues and pull requests are welcome.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Thanks to all contributors:
