feat: smart force-push protection with backup strategies (#206)

* 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
This commit is contained in:
ARUNAVO RAY
2026-03-02 15:48:59 +05:30
committed by GitHub
parent 58e0194aa6
commit 98da7065e0
24 changed files with 1712 additions and 151 deletions

View File

@@ -6,13 +6,13 @@
* by the 02-mirror-workflow suite.
*
* What is tested:
* B1. Enable backupBeforeSync in config
* B1. Enable backupStrategy: "always" in config
* B2. Confirm mirrored repos exist in Gitea (precondition)
* B3. Trigger a re-sync with backup enabled — verify the backup code path
* runs (snapshot activity entries appear in the activity log)
* B4. Inspect activity log for snapshot-related entries
* B5. Enable blockSyncOnBackupFailure and verify the flag is persisted
* B6. Disable backup and verify config resets cleanly
* B6. Disable backup (backupStrategy: "disabled") and verify config resets cleanly
*/
import { test, expect } from "@playwright/test";
@@ -54,10 +54,10 @@ test.describe("E2E: Backup configuration", () => {
const giteaToken = giteaApi.getTokenValue();
expect(giteaToken, "Gitea token required").toBeTruthy();
// Save config with backup enabled
// Save config with backup strategy set to "always"
await saveConfig(request, giteaToken, appCookies, {
giteaConfig: {
backupBeforeSync: true,
backupStrategy: "always",
blockSyncOnBackupFailure: false,
backupRetentionCount: 5,
backupDirectory: "data/repo-backups",
@@ -75,7 +75,7 @@ test.describe("E2E: Backup configuration", () => {
const configData = await configResp.json();
const giteaCfg = configData.giteaConfig ?? configData.gitea ?? {};
console.log(
`[Backup] Config saved: backupBeforeSync=${giteaCfg.backupBeforeSync}, blockOnFailure=${giteaCfg.blockSyncOnBackupFailure}`,
`[Backup] Config saved: backupStrategy=${giteaCfg.backupStrategy}, blockOnFailure=${giteaCfg.blockSyncOnBackupFailure}`,
);
}
});
@@ -202,7 +202,7 @@ test.describe("E2E: Backup configuration", () => {
expect(
backupJobs.length,
"Expected at least one backup/snapshot activity entry when " +
"backupBeforeSync is enabled and repos exist in Gitea",
"backupStrategy is 'always' and repos exist in Gitea",
).toBeGreaterThan(0);
// Check for any failed backups
@@ -247,7 +247,7 @@ test.describe("E2E: Backup configuration", () => {
// Update config to block sync on backup failure
await saveConfig(request, giteaToken, appCookies, {
giteaConfig: {
backupBeforeSync: true,
backupStrategy: "always",
blockSyncOnBackupFailure: true,
backupRetentionCount: 5,
backupDirectory: "data/repo-backups",
@@ -284,7 +284,7 @@ test.describe("E2E: Backup configuration", () => {
// Disable backup
await saveConfig(request, giteaToken, appCookies, {
giteaConfig: {
backupBeforeSync: false,
backupStrategy: "disabled",
blockSyncOnBackupFailure: false,
},
});
@@ -297,7 +297,7 @@ test.describe("E2E: Backup configuration", () => {
const configData = await configResp.json();
const giteaCfg = configData.giteaConfig ?? configData.gitea ?? {};
console.log(
`[Backup] After disable: backupBeforeSync=${giteaCfg.backupBeforeSync}`,
`[Backup] After disable: backupStrategy=${giteaCfg.backupStrategy}`,
);
}
console.log("[Backup] Backup configuration test complete");