This document describes the smart force-push protection system introduced in gitea-mirror v3.11.0+.
GitHub repositories can be force-pushed at any time — rewriting history, deleting branches, or replacing commits entirely. When gitea-mirror syncs a force-pushed repo, the old history in Gitea is silently overwritten. Files, commits, and branches disappear with no way to recover them.
The original workaround (backupBeforeSync: true) created a full git bundle backup before every sync. This doesn't scale — a user with 100+ GiB of mirrors would need up to 2 TB of backup storage with default retention settings, even though force-pushes are rare.
Instead of backing up everything every time, the system detects force-pushes before they happen and only acts when needed.
Before each sync, the app compares branch SHAs between Gitea (the mirror) and GitHub (the source):
- Fetch branches from both sides — lightweight API calls to get branch names and their latest commit SHAs
- Compare each branch:
- SHAs match → nothing changed, no action needed
- SHAs differ → check if the change is a normal push or a force-push
- Ancestry check — for branches with different SHAs, call GitHub's compare API to determine if the new SHA is a descendant of the old one:
- Fast-forward (new SHA descends from old) → normal push, safe to sync
- Diverged (histories split) → force-push detected
- 404 (old SHA doesn't exist on GitHub anymore) → history was rewritten, force-push detected
- Branch deleted on GitHub → flagged as destructive change
Depends on the configured strategy (see below):
- Backup strategies (
always,on-force-push): create a git bundle snapshot, then sync - Block strategy (
block-on-force-push): halt the sync, mark the repo aspending-approval, wait for user action
If detection itself fails (GitHub rate limits, network errors, API outages), sync proceeds normally. Detection never blocks a sync due to its own failure. Individual branch check failures are skipped — one flaky branch doesn't affect the others.
Configure via Settings → GitHub Configuration → Destructive Update Protection.
| Strategy | What It Does | Storage Cost | Best For |
|---|---|---|---|
| Disabled | No detection, no backups | Zero | Repos you don't care about losing |
| Always Backup | Snapshot before every sync (original behavior) | High | Small mirror sets, maximum safety |
| Smart (default) | Detect force-pushes, backup only when found | Near-zero normally | Most users — efficient protection |
| Block & Approve | Detect force-pushes, block sync until approved | Zero | Critical repos needing manual review |
Syncs proceed without any detection or backup. If a force-push happens on GitHub, the mirror silently overwrites.
Creates a git bundle snapshot before every sync regardless of whether a force-push occurred. This is the legacy behavior (equivalent to the old backupBeforeSync: true). Safe but expensive for large mirror sets.
Runs the force-push detection before each sync. On normal days (no force-pushes), syncs proceed without any backup overhead. When a force-push is detected, a snapshot is created before the sync runs.
This gives you protection when it matters with near-zero cost when it doesn't.
Runs detection and, when a force-push is found, blocks the sync entirely. The repository is marked as pending-approval and excluded from future scheduled syncs until you take action:
- Approve: creates a backup first, then syncs (safe)
- Dismiss: clears the flag and resumes normal syncing (no backup)
Use this for repos where you want manual control over destructive changes.
These appear when any non-disabled strategy is selected:
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: 5.
Maximum age (in days) for backup snapshots. Bundles older than this are deleted during retention enforcement, though at least one bundle is always kept. Set to 0 to disable time-based retention. Default: 30.
Where git bundle backups are stored. Default: data/repo-backups. Bundles are organized as <directory>/<owner>/<repo>/<timestamp>.bundle.
Available for Always Backup and Smart strategies. When enabled, if the snapshot creation fails (disk full, permissions error, etc.), the sync is also blocked. When disabled, sync continues even if the snapshot couldn't be created.
Recommended: enabled if you rely on backups for recovery.
The old backupBeforeSync boolean is still recognized:
| Old Setting | New Equivalent |
|---|---|
backupBeforeSync: true |
backupStrategy: "on-force-push" |
backupBeforeSync: false |
backupStrategy: "disabled" |
| Neither set | backupStrategy: "on-force-push" (new default) |
Existing configurations are automatically mapped. The old field is deprecated but will continue to work.
No new environment variables are required. The backup strategy is configured through the web UI and stored in the database alongside other config.
When using the block-on-force-push strategy, repos that are blocked can be managed via the API:
# Approve sync (creates backup first, then syncs)
curl -X POST http://localhost:4321/api/job/approve-sync \
-H "Content-Type: application/json" \
-H "Cookie: <session>" \
-d '{"repositoryIds": ["<id>"], "action": "approve"}'
# Dismiss (clear the block, resume normal syncing)
curl -X POST http://localhost:4321/api/job/approve-sync \
-H "Content-Type: application/json" \
-H "Cookie: <session>" \
-d '{"repositoryIds": ["<id>"], "action": "dismiss"}'Blocked repos also show an Approve / Dismiss button in the repository table UI.
| File | Purpose |
|---|---|
src/lib/utils/force-push-detection.ts |
Core detection: fetch branches, compare SHAs, check ancestry |
src/lib/repo-backup.ts |
Strategy resolver, backup decision logic, bundle creation |
src/lib/gitea-enhanced.ts |
Sync flow integration (calls detection + backup before mirror-sync) |
src/pages/api/job/approve-sync.ts |
Approve/dismiss API endpoint |
src/components/config/GitHubConfigForm.tsx |
Strategy selector UI |
src/components/repositories/RepositoryTable.tsx |
Pending-approval badge + action buttons |
syncGiteaRepoEnhanced()
│
├─ Resolve backup strategy (config → backupStrategy → backupBeforeSync → default)
│
├─ If strategy needs detection ("on-force-push" or "block-on-force-push"):
│ │
│ ├─ fetchGiteaBranches() — GET /api/v1/repos/{owner}/{repo}/branches
│ ├─ fetchGitHubBranches() — octokit.paginate(repos.listBranches)
│ │
│ └─ For each Gitea branch where SHA differs:
│ └─ checkAncestry() — octokit.repos.compareCommits()
│ ├─ "ahead" or "identical" → fast-forward (safe)
│ ├─ "diverged" or "behind" → force-push detected
│ └─ 404/422 → old SHA gone → force-push detected
│
├─ If "block-on-force-push" + detected:
│ └─ Set repo status to "pending-approval", return early
│
├─ If backup needed (always, or on-force-push + detected):
│ └─ Create git bundle snapshot
│
└─ Proceed to mirror-sync
Repos stuck in "pending-approval": Use the Approve or Dismiss buttons in the repository table, or call the approve-sync API endpoint.
Detection always skipped: Check the activity log for skip reasons. Common causes: Gitea repo not yet mirrored (first sync), GitHub API rate limits, network errors. All are fail-open by design.
Backups consuming too much space: Lower the retention count, or switch from "Always Backup" to "Smart" which only creates backups on actual force-pushes.
False positives: The detection compares branch-by-branch. A rebase (which is a force-push) will correctly trigger detection. If you routinely rebase branches, consider using "Smart" instead of "Block & Approve" to avoid constant approval prompts.