How to exercise syncerd git-sync on your own machine, from a build with no
credentials through a real GitHub to GitLab mirror and back.
Every command here has been run against this branch. Expected output is quoted verbatim where it matters.
Read the safety note before Phase 4. git-sync force pushes and prunes refs
at the destination. Use throwaway repositories.
| Phase | What it proves | Credentials |
|---|---|---|
| 0 | The tool builds on every release target | none |
| 1 | Unit and integration suites pass | none |
| 2 | The CLI surface and config validation behave | none |
| 3 | Discovery and filtering work against a real org | read only |
| 4 | A real mirror lands | read and write |
| 5 | Unchanged repos are skipped | read and write |
| 6 | A new branch is picked up | read and write |
| 7 | A deleted branch is pruned | read and write |
| 8 | The adopt guard refuses an unknown destination | read and write |
| 9 | The reverse direction works | read and write |
| 10 | Slack reports correctly | read and write |
| 11 | Scheduled mode runs | read and write |
go version # 1.25 or newer
git --version # 2.30 or newer, git-sync refuses below thisgit must be on PATH. git-sync shells out to it; there is no pure-Go
fallback. The published Docker image now installs git, but a local build is
still the fastest way to iterate through this runbook.
cd path/to/syncerd
go build -o ./syncerd .
./syncerd --version
# Put the binary on PATH so later phases, which cd into a scratch
# directory, can call it as plain `syncerd`.
export PATH="$PWD:$PATH"Confirm all three release targets compile:
for os in linux darwin windows; do
GOOS=$os GOARCH=amd64 go build -o /dev/null ./... && echo "$os ok"
doneExpect linux ok, darwin ok, windows ok.
go test ./...
go test ./... -race
go vet ./...
gofmt -l ./internal ./main.go # must print nothingThe internal/gitsync suite creates real bare repositories under t.TempDir()
and drives the actual git binary, so it covers clone, fetch, push, prune,
force-push refusal, and the empty-cache guard without touching the network.
Run just the mirror mechanics if you want to watch them:
go test ./internal/gitsync/ -run 'TestPush|TestSyncCache|TestEngine' -vVerify the existing Docker image sync is untouched:
./syncerd sync --once --config /dev/nullExpected, byte for byte, unchanged from before this feature:
at least one destination is required
Verify git-sync is inert without config:
./syncerd git-sync --once --config /dev/nullgit configuration is required for git-sync
Now check the validator catches the common mistakes. Each of these fails at config load, before any network call.
mkdir -p /tmp/gitsync-rb && cd /tmp/gitsync-rbMissing token:
cat > 01-no-token.yaml <<'EOF'
git:
providers:
- name: gh
type: github
owner: acme
mirrors:
- name: gh-to-gl
source: gh
destination: gh
EOF
syncerd git-sync --once --config 01-no-token.yamlgit.providers[0].token is required for type "github"; set it in the config or as SYNCERD_GIT_GH_TOKEN
Bitbucket without an email (app passwords were retired 2026-07-28, so API tokens authenticate with the account email):
cat > 02-bitbucket.yaml <<'EOF'
git:
providers:
- name: gh
type: github
owner: acme
token: ghp_placeholder
- name: bb
type: bitbucket
owner: workspace
token: tok_placeholder
mirrors:
- name: gh-to-bb
source: gh
destination: bb
EOF
syncerd git-sync --once --config 02-bitbucket.yamlgit.providers[1].email is required for bitbucket: app passwords were retired on 2026-07-28 and API tokens authenticate with the account email
A nested name template aimed at GitHub. GitHub repository names cannot contain a slash, so this is refused at startup rather than at push time:
cat > 03-nested.yaml <<'EOF'
git:
providers:
- name: gl
type: gitlab
owner: mygroup
token: glpat_placeholder
- name: gh
type: github
owner: acme
token: ghp_placeholder
mirrors:
- name: gl-to-gh
source: gl
destination: gh
name_template: "{{ .Owner }}/{{ .Repo }}"
EOF
syncerd git-sync --once --config 03-nested.yamlfailed to create git syncer: mirror "gl-to-gh": name_template renders a nested name but destination "gh" does not support nested paths
Observability flags need no credentials either and can be checked here:
--report <path> (a JSON run summary), --metrics-file <path> (Prometheus
textfile collector metrics), and --log-format json (structured logging;
text stays the default). All three are opt in, all three are shared with
sync, and none change what a run does. See
Observability in the README for the alert
query and the one-file-per-command note.
| Provider | Token | Scopes |
|---|---|---|
| GitHub | fine-grained PAT | Metadata read, Contents read, Administration write (only if create_missing); a fine grained token must also grant access to each private repository you expect to be mirrored, or discovery reports fewer repositories than the source holds |
| GitLab | group access token | api to create projects, plus write_repository |
Environment variable names derive from the provider name in your config, upper cased with non alphanumerics mapped to underscore:
export SYNCERD_GIT_GH_TOKEN='ghp_your_real_token'
export SYNCERD_GIT_GL_TOKEN='glpat-your_real_token'A provider named gh-mirrors would read SYNCERD_GIT_GH_MIRRORS_TOKEN. Two
provider names that normalise to the same variable are rejected at config load.
Values written in the config file win over the environment. That is
deliberate, so a deployment can pin a secret, but it means a stray token: in
the file silently shadows your exported variable.
cd /tmp/gitsync-rb
cat > mirror.yaml <<'EOF'
git:
providers:
- name: gh
type: github
owner: YOUR_GITHUB_ORG_OR_USER
- name: gl
type: gitlab
owner: YOUR_GITLAB_GROUP
mirrors:
- name: gh-to-gl
source: gh
destination: gl
filters:
include: ["syncerd-rbtest-*"]
skip_archived: true
skip_forks: true
create_missing: true
visibility: private
# Keep both under the test directory. See "state_path" under Gotchas.
work_dir: /tmp/gitsync-rb/cache
state_path: /tmp/gitsync-rb/cache/git-state.json
concurrency: 2
EOFCreate two throwaway repos in your GitHub org named syncerd-rbtest-one and
syncerd-rbtest-two, each with at least one commit and one tag. The include
glob keeps everything else in the org out of scope.
syncerd git-sync --dry-run --config mirror.yaml--dry-run implies --once. It never creates a destination repository, never
pushes, never writes state, and never posts to Slack.
Look for, per repository:
mirror gh-to-gl: 2 repositories discovered, 2 selected
mirror gh-to-gl: would create destination syncerd-rbtest-one
mirror gh-to-gl: acme/syncerd-rbtest-one -> syncerd-rbtest-one would apply N ref change(s):
...one line per ref...
Those per-ref lines are the point of the dry run. On a destination that already
exists, deletions appear with a leading -, which is your last chance to catch
a mirror pointed somewhere wrong.
Confirm nothing was written:
ls /tmp/gitsync-rb/cache/git-state.json 2>&1 # expect: No such file or directoryThen check your GitLab group in a browser: no projects should have appeared.
Safety. From here the tool writes to the destination.
push_modedefaults tomirror, which deletes destination branches and tags absent at source and force pushes rewritten history. Point it only at a group you are willing to lose. The adopt guard (Phase 8) protects a destination that already has content, but nothing protects you from choosing the wrong empty group.
syncerd git-sync --once --config mirror.yamlExpect per repository:
mirror gh-to-gl: acme/syncerd-rbtest-one -> syncerd-rbtest-one (N pushed, 0 deleted)
and a closing line:
Git mirror run finished in Xs: 2 mirrored, 0 skipped, 0 failures
Verify:
cat /tmp/gitsync-rb/cache/git-state.json | head -20
ls /tmp/gitsync-rb/cache/gh-to-gl/ # one bare cache dir per repo
git ls-remote https://gitlab.com/YOUR_GITLAB_GROUP/syncerd-rbtest-one.gitThe ls-remote output must show every branch and tag from the source. Only
refs/heads/* and refs/tags/* are mirrored; host internal namespaces such as
refs/pull/* and refs/merge_requests/* are deliberately excluded.
Run the exact same command again with nothing changed:
syncerd git-sync --once --config mirror.yamlmirror gh-to-gl: acme/syncerd-rbtest-one unchanged, skipping
Git mirror run finished in Xs: 0 mirrored, 2 skipped, 0 failures
This is the fingerprint check. It runs git ls-remote against the source,
hashes the ref set, and compares it to state. An unchanged repository is never
cloned and never pushed, so a steady-state run is nearly free. No Slack message
is sent when nothing happened.
Push a new branch to one source repo, then rerun:
# in a clone of syncerd-rbtest-one
git checkout -b feature/runbook
git commit --allow-empty -m "runbook test"
git push origin feature/runbooksyncerd git-sync --once --config mirror.yamlExpect exactly one repository to move and the other to skip:
Git mirror run finished in Xs: 1 mirrored, 1 skipped, 0 failures
Confirm the branch arrived:
git ls-remote https://gitlab.com/YOUR_GITLAB_GROUP/syncerd-rbtest-one.git 'refs/heads/feature/*'Delete that branch at the source and rerun:
git push origin --delete feature/runbook
syncerd git-sync --once --config mirror.yamlmirror gh-to-gl: acme/syncerd-rbtest-one -> syncerd-rbtest-one (0 pushed, 1 deleted)
Confirm it is gone at the destination:
git ls-remote https://gitlab.com/YOUR_GITLAB_GROUP/syncerd-rbtest-one.git 'refs/heads/feature/*'
# expect no outputTo see the safer modes, set push_mode: additive on the mirror and repeat: the
destination branch survives a source deletion. push_mode: fast-forward refuses
any non fast forward update and reports it as a run failure.
This is the protection worth verifying yourself, because it is what stands between a misconfigured mirror and someone's work.
Simulate a first run against a destination that already has content:
# Throw away the state so SyncerD forgets it has ever seen these destinations
rm /tmp/gitsync-rb/cache/git-state.json
syncerd git-sync --once --config mirror.yamlExpect a failure per repository, not a push:
destination already has N refs and this mirror has no prior state; set adopt: true on mirror "gh-to-gl" to overwrite it
Git mirror run finished in Xs: 0 mirrored, 0 skipped, 2 failures
Confirm the destination is untouched. Then opt in deliberately:
# add "adopt: true" under the gh-to-gl mirror, then
syncerd git-sync --once --config mirror.yamlNow it proceeds. Remove adopt: true again afterwards.
The same guard fires when you retarget an existing mirror. Change
name_template on a mirror that already has state and you will see:
mirror gh-to-gl: acme/syncerd-rbtest-one destination changed from "syncerd-rbtest-one" to "...", treating as a first run
followed by the adopt refusal if that new destination already holds refs.
Any provider can be either end. Add a second mirror going back:
- name: gl-to-gh
source: gl
destination: gh
filters:
include: ["syncerd-rbtest-*"]
create_missing: trueBoth mirrors run in the same invocation and keep separate state, keyed by mirror
name. Watch for two N repositories discovered lines.
Add the same webhook block image sync already uses:
slack:
enabled: true
webhook_url: "https://hooks.slack.com/services/..."
notify_on_new: true
notify_on_error: true
message_format: detailed # groups by mirror name; "compact" is the defaultSlack uses an incoming webhook URL, not a bot token. There are no OAuth scopes to configure.
Then force a change and rerun. Expect one Block Kit message summarising the run. Confirm:
- a dry run posts nothing,
- a run where everything was skipped posts nothing,
- no token appears anywhere in the message.
To see a failure message, point a mirror at a group your token cannot write to.
Drop --once to run the cron loop:
# add: schedule: "*/5 * * * *" under git:
syncerd git-sync --config mirror.yamlIt runs once immediately, then on the schedule, until SIGINT or SIGTERM. An invalid cron expression is rejected before the first run. If a run outlives its interval the next tick is skipped rather than started concurrently.
| Symptom | Cause | Fix |
|---|---|---|
git is required for git-sync but was not runnable |
no git on PATH |
install git 2.30+ |
git X.Y found, but git-sync requires 2.30 or newer |
old git | upgrade |
another git-sync is already running against work_dir "..." |
a second process holds the cache lock | wait, or check for a stuck process; the lock releases automatically if the process died |
destination already has N refs and this mirror has no prior state |
adopt guard | intended; confirm the destination is right, then set adopt: true |
refusing a mirror push from an empty cache |
source resolved to zero refs | check the source repo and token; this guard stops a full destination wipe |
cannot inspect destination and create_missing is off |
destination unreachable | fix credentials or network; the tool fails closed rather than pushing blind |
source repositories "a" and "b" both render to destination "x" |
two sources collide under name_template |
make the template produce distinct names |
provider "x" has unsupported type "..." |
the type string is not one of the five registered types | use github, gitlab, bitbucket, azuredevops, or codecommit |
| Everything skipped, nothing mirrored | fingerprints match | expected; delete the state file to force a full pass |
| Every repo fails the adopt guard on run two | state file was lost between runs | see state_path below |
Useful inspection commands:
# what the tool thinks it has already done
cat /tmp/gitsync-rb/cache/git-state.json | python3 -m json.tool
# the clone cache
ls -la /tmp/gitsync-rb/cache/gh-to-gl/
# what the source actually advertises
git ls-remote --heads --tags https://github.com/ORG/REPO.gitstate_path and work_dir default to different places. work_dir defaults
to /var/lib/syncerd/git, an absolute path suited to a mounted volume, while
state_path defaults to .syncerd-git-state.json, relative to the working
directory. If you mount a volume for the cache but not for state, state is lost
on every restart, and from the second run onward every repository fails the
adopt guard against destinations it populated itself. Set both explicitly,
under the same volume, as this runbook's config does. This default needs
settling before the Helm chart lands.
State is written at the end of a run. A run killed partway through loses the
fingerprints for repositories it already pushed, producing the same adopt guard
storm on the next run. Recover by setting adopt: true for one run, then
removing it. Incremental saving is a known follow-up.
All five provider types run. github, gitlab, bitbucket, azuredevops,
and codecommit all construct and mirror successfully; any of them can be a
source or a destination. Three real, permanent limitations remain, so a
config that looks valid can still fail at the credential or network step:
- CodeCommit: SyncerD does not derive SigV4 git credentials, so IRSA and
instance roles cover listing and creating repositories, but the git
transport needs static IAM HTTPS Git credentials (
git_username/git_password, orSYNCERD_GIT_<NAME>_GIT_USERNAME/SYNCERD_GIT_<NAME>_GIT_PASSWORD), which are only issuable to an IAM user. - Azure DevOps Entra mode: the operator supplies the access token;
SyncerD does not acquire one from Azure AD. It is supplied through the
same
SYNCERD_GIT_<NAME>_TOKENvariable used for a PAT. - Bitbucket: Cloud only. The
api_urloverride changes the host but the request paths are Cloud shaped, so Bitbucket Data Center is not supported. Bitbucket also has no archived concept, soskip_archivedhas no effect for a Bitbucket source.
A personal account source needs a token belonging to that account.
GitHub's GET /users/{owner}/repos returns public repositories only, whatever
token is presented, so SyncerD lists a personal account through
GET /user/repos instead, which reports private repositories too. That
endpoint only ever describes the account the token belongs to. Mirroring
someone else's personal account therefore discovers its public repositories
and nothing more, because no credential can see further. Organisation owners
are unaffected: GET /orgs/{org}/repos reports private repositories to any
token with access. The same shape applies to GitLab, where a user namespace
falls back to GET /users/{owner}/projects.
Released binaries need git on PATH. goreleaser ships a bare binary with no bundled git; install git 2.30 or newer on any host that runs a release binary. The Docker image installs it for you.
Branches, tags, and optionally pull request heads are mirrored. Not issues,
pull request discussions, wikis, releases, or Git LFS objects. A repository
using LFS mirrors its pointers, not its objects. With pull_requests.enabled
set on a mirror, the commits behind each open pull request opened from a fork
are pushed as a branch; see Mirroring pull request heads above.
git:
mirrors:
- name: gh-to-gl
source: gh
destination: gl
pull_requests:
enabled: true # default false
branch_prefix: syncerd/pr # default
states: [open] # default, and the only accepted value todayA pull request opened from a branch of the source repository needs nothing: its
commits already arrive with the ordinary branch mirror, under their own branch
name. A pull request opened from a fork has no branch in the source
repository, so its commits reach the destination only with this setting on, as
refs/heads/<branch_prefix>/<number>.
Under the default mirror push mode the branch disappears from the destination
on the first run after the pull request closes: the source stops listing it, and
prune removes what the source no longer has.
Set mirror_objects and each open source pull request is recreated at the
destination as a real pull request, updated on every run, with its discussion,
its inline review comments, and its review verdicts:
pull_requests:
enabled: true
mirror_objects: true # default false, requires enabled
comments: true # default true once mirror_objects is on
reviews: true # default true once mirror_objects is on
labels: true # default trueWith mirror_objects on, every mirrored pull request gets a
branch_prefix branch, including one whose head is already a branch of the
source repository. A destination pull request has to name one head branch, and
a uniform name is what keeps the mapping simple. With it off, the P1 rule
stands: fork heads only.
A merged source pull request is closed at the destination, not merged. A merge performed at the destination would create a merge commit that differs from the source's, and the branch mirror then force overwrites it, so the destination would report a merge whose commit no longer exists. SyncerD closes the pull request instead and comments naming the source merge commit, which by then genuinely is on the destination's base branch.
Review verdicts are mirrored as text, never as real approvals. An approval posted by SyncerD's token would be a review nobody performed, and on a destination with required approvals it would satisfy a rule no human satisfied.
Mentions and issue references are neutralised. @someone in a mirrored
body would otherwise notify whichever destination account owns that handle, and
#123 would cross link to an unrelated destination issue. Both are rewritten
to render identically and link to nothing. Text inside fenced code blocks is
left byte for byte.
Everything SyncerD writes carries a hidden marker. That is how a later run updates its own comments in place instead of posting duplicates, and how a destination pull request is re-identified after a lost state file, so losing state causes re-inspection rather than a second copy of everything.
Only comments SyncerD wrote are ever edited or deleted. A comment written by a person at the destination is left alone, even though the source is the authority: the source is authority over what it published, not over what somebody else said.
Every provider type can mirror pull requests, in any direction. They do not agree on what a pull request has, so some things are approximated and some are lost. This table is the whole of it:
| GitHub | GitLab | Bitbucket | Azure DevOps | CodeCommit | |
|---|---|---|---|---|---|
| Reopen a closed mirror | yes | yes | no | yes | no |
| Draft | yes | via a Draft: title prefix |
yes | on create only | no |
| Update watermark | yes | yes | yes | none reported, so every run reconciles | yes |
| Labels | yes | yes | no | yes | no |
| Inline comment anchors | yes | needs the source to report commit SHAs | yes | yes | yes |
| Comment deletion | removed | removed | removed | tombstone | content blanked |
Nothing here fails a run. A capability the destination lacks is logged and skipped, never faked:
- Reopen. Bitbucket has no reopen endpoint and CodeCommit permits only
OPENtoCLOSED. A destination pull request closed by hand while its source is still open stays closed, and SyncerD warns once and records the divergence rather than opening a second pull request for the same work. Nothing else is attempted on it, because both providers reject an update to a closed pull request. Reopen it by hand and the next run picks it up again: the recorded divergence is re-checked, never trusted. - Draft. GitLab has no draft field, so a draft is expressed by prefixing
the title with
Draft:, and the prefix is stripped when the source leaves draft. This is the one place SyncerD does not copy a title verbatim. CodeCommit ignores the flag. - Labels. Ignored on Bitbucket and CodeCommit, which have none.
- Inline comments. GitLab and CodeCommit refuse to anchor a comment without commit SHAs. When the source did not report them, the comment is posted as a discussion comment naming the file and line instead of being dropped.
- Deletion. Azure DevOps and CodeCommit delete softly, so a comment deleted at the source leaves a tombstone or an empty comment rather than disappearing.
CodeCommit is also the expensive one: its API returns pull request ids only, so each listing costs one extra call per pull request, and it cannot search by branch at all, so SyncerD enumerates once per repository per run and matches locally. A lookup that misses among the open pull requests enumerates the closed ones too, which is what stops a lost state file from producing a duplicate.
Azure DevOps reports no update timestamp on a pull request, so there is nothing to watermark against and every pull request is reconciled on every run. That is slower than the other four and it is the only way to notice an edit there at all.
Read this before enabling it. A fork pull request head is code written by
anyone who can open a pull request against the source. Turning this on pushes
that code to a destination branch, where it arrives as an ordinary push from a
trusted mirror rather than as a fork pull request. If destination CI builds on
branch push, it will run that code with whatever credentials that CI holds, and
none of the source's fork pull request protections apply. Restrict destination
CI to the branches your team actually builds and exclude the branch_prefix
namespace. It is one contiguous namespace so a single glob covers it.
A repository whose own branches live under branch_prefix is refused with an
error naming the branch, because mirroring both would have the two overwrite
each other on every run. Change branch_prefix for that mirror, or exclude the
repository.
Only GitHub sources can list pull requests today. A mirror that enables
pull_requests with any other source type is rejected at startup.
Every other test in this repository checks SyncerD against fakes written by whoever wrote the code, and that has already proved insufficient: three defects that made pull request mirroring inoperable survived a green suite, a passing verification run, and a review of their own change, because the fakes agreed with the mistake. A real API does not.
export SYNCERD_LIVE_GITHUB_TOKEN=ghp_... # repo scope
export SYNCERD_LIVE_GITHUB_OWNER=your-account
make test-liveIt creates two throwaway private repositories named syncerd-live-*, seeds
one with a branch, opens a pull request, mirrors the branches and then the
pull request to the other, and checks what actually landed: that the
destination pull request exists and is findable by its head branch, that
the body carries the marker and no live mention, that a second run changes
nothing, that a comment mirrors exactly once, and that closing the source
closes the mirror. Then it deletes both repositories. Set
SYNCERD_LIVE_KEEP=1 to leave them behind and look.
Run it before a release that touches provider code. It is the only check that would have caught the defects above.
rm -rf /tmp/gitsync-rb
# then delete the throwaway repos in GitHub and GitLab