mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-13 22:12:54 +03:00
* feat: smart force-push protection with backup strategies (#187) Replace blunt `backupBeforeSync` boolean with `backupStrategy` enum offering four modes: disabled, always, on-force-push (default), and block-on-force-push. This dramatically reduces backup storage for large mirror collections by only creating snapshots when force-pushes are actually detected. Detection works by comparing branch SHAs between Gitea and GitHub APIs before each sync — no git cloning required. Fail-open design ensures detection errors never block sync. Key changes: - Add force-push detection module (branch SHA comparison via APIs) - Add backup strategy resolver with backward-compat migration - Add pending-approval repo status with approve/dismiss UI + API - Add block-on-force-push mode requiring manual approval - Fix checkAncestry to only treat 404 as confirmed force-push (transient errors skip branch instead of false-positive blocking) - Fix approve-sync to bypass detection gate (skipForcePushDetection) - Fix backup execution to not be hard-gated by deprecated flag - Persist backupStrategy through config-mapper round-trip * fix: resolve four bugs in smart force-push protection P0: Approve flow re-blocks itself — approve-sync now calls syncGiteaRepoEnhanced with skipForcePushDetection: true so the detection+block gate is bypassed on approved syncs. P1: backupStrategy not persisted — added to both directions of the config-mapper. Don't inject a default in the mapper; let resolveBackupStrategy handle fallback so legacy backupBeforeSync still works for E2E tests and existing configs. P1: Backup hard-gated by deprecated backupBeforeSync — added force flag to createPreSyncBundleBackup; strategy-driven callers and approve-sync pass force: true to bypass the legacy guard. P1: checkAncestry false positives — now only returns false for 404/422 (confirmed force-push). Transient errors (rate limits, 500s) are rethrown so detectForcePush skips that branch (fail-open). * test(e2e): migrate backup tests from backupBeforeSync to backupStrategy Update E2E tests to use the new backupStrategy enum ("always", "disabled") instead of the deprecated backupBeforeSync boolean. * docs: add backup strategy UI screenshot * refactor(ui): move Destructive Update Protection to GitHub config tab Relocates the backup strategy section from GiteaConfigForm to GitHubConfigForm since it protects against GitHub-side force-pushes. Adds ShieldAlert icon to match other section header patterns. * docs: add force-push protection documentation and Beta badge Add docs/FORCE_PUSH_PROTECTION.md covering detection mechanism, backup strategies, API usage, and troubleshooting. Link it from README features list and support section. Mark the feature as Beta in the UI with an outline badge. * fix(ui): match Beta badge style to Git LFS badge
180 lines
8.3 KiB
Markdown
180 lines
8.3 KiB
Markdown
# Force-Push Protection
|
|
|
|
This document describes the smart force-push protection system introduced in gitea-mirror v3.11.0+.
|
|
|
|
## The Problem
|
|
|
|
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.
|
|
|
|
## Solution: Smart Detection
|
|
|
|
Instead of backing up everything every time, the system detects force-pushes **before** they happen and only acts when needed.
|
|
|
|
### How Detection Works
|
|
|
|
Before each sync, the app compares branch SHAs between Gitea (the mirror) and GitHub (the source):
|
|
|
|
1. **Fetch branches from both sides** — lightweight API calls to get branch names and their latest commit SHAs
|
|
2. **Compare each branch**:
|
|
- SHAs match → nothing changed, no action needed
|
|
- SHAs differ → check if the change is a normal push or a force-push
|
|
3. **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
|
|
|
|
### What Happens on Detection
|
|
|
|
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 as `pending-approval`, wait for user action
|
|
|
|
### Fail-Open Design
|
|
|
|
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.
|
|
|
|
## Backup Strategies
|
|
|
|
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 |
|
|
|
|
### Strategy Details
|
|
|
|
#### Disabled
|
|
|
|
Syncs proceed without any detection or backup. If a force-push happens on GitHub, the mirror silently overwrites.
|
|
|
|
#### Always Backup
|
|
|
|
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.
|
|
|
|
#### Smart (`on-force-push`) — Recommended
|
|
|
|
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.
|
|
|
|
#### Block & Approve (`block-on-force-push`)
|
|
|
|
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.
|
|
|
|
## Additional Settings
|
|
|
|
These appear when any non-disabled strategy is selected:
|
|
|
|
### Snapshot Retention Count
|
|
|
|
How many backup snapshots to keep per repository. Oldest snapshots are deleted when this limit is exceeded. Default: **20**.
|
|
|
|
### Snapshot Directory
|
|
|
|
Where git bundle backups are stored. Default: **`data/repo-backups`**. Bundles are organized as `<directory>/<owner>/<repo>/<timestamp>.bundle`.
|
|
|
|
### Block Sync on Snapshot Failure
|
|
|
|
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.
|
|
|
|
## Backward Compatibility
|
|
|
|
The old `backupBeforeSync` boolean is still recognized:
|
|
|
|
| Old Setting | New Equivalent |
|
|
|---|---|
|
|
| `backupBeforeSync: true` | `backupStrategy: "always"` |
|
|
| `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.
|
|
|
|
## Environment Variables
|
|
|
|
No new environment variables are required. The backup strategy is configured through the web UI and stored in the database alongside other config.
|
|
|
|
## API
|
|
|
|
### Approve/Dismiss Blocked Repos
|
|
|
|
When using the `block-on-force-push` strategy, repos that are blocked can be managed via the API:
|
|
|
|
```bash
|
|
# 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.
|
|
|
|
## Architecture
|
|
|
|
### Key Files
|
|
|
|
| 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 |
|
|
|
|
### Detection Flow
|
|
|
|
```
|
|
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
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
**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.
|