From 98da7065e001427186c574db64337485d1dc3445 Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Mon, 2 Mar 2026 15:48:59 +0530 Subject: [PATCH] feat: smart force-push protection with backup strategies (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .github/screenshots/backup-strategy-ui.png | Bin 0 -> 34330 bytes README.md | 2 + docs/FORCE_PUSH_PROTECTION.md | 179 ++++++++++ src/components/config/ConfigTabs.tsx | 13 +- src/components/config/GitHubConfigForm.tsx | 153 ++++++++- src/components/config/GiteaConfigForm.tsx | 74 +--- src/components/repositories/Repository.tsx | 76 +++++ .../repositories/RepositoryTable.tsx | 76 ++++- src/lib/db/schema.ts | 12 +- src/lib/gitea-enhanced.ts | 180 +++++++--- src/lib/repo-backup.test.ts | 135 +++++++- src/lib/repo-backup.ts | 94 +++++- src/lib/scheduler-service.ts | 20 +- src/lib/utils.ts | 2 + src/lib/utils/config-defaults.ts | 3 +- src/lib/utils/config-mapper.ts | 2 + src/lib/utils/force-push-detection.test.ts | 319 ++++++++++++++++++ src/lib/utils/force-push-detection.ts | 286 ++++++++++++++++ src/pages/api/job/approve-sync.ts | 202 +++++++++++ src/types/Repository.ts | 1 + src/types/config.ts | 4 +- tests/e2e/03-backup.spec.ts | 18 +- tests/e2e/04-force-push.spec.ts | 10 +- tests/e2e/helpers.ts | 2 +- 24 files changed, 1712 insertions(+), 151 deletions(-) create mode 100644 .github/screenshots/backup-strategy-ui.png create mode 100644 docs/FORCE_PUSH_PROTECTION.md create mode 100644 src/lib/utils/force-push-detection.test.ts create mode 100644 src/lib/utils/force-push-detection.ts create mode 100644 src/pages/api/job/approve-sync.ts diff --git a/.github/screenshots/backup-strategy-ui.png b/.github/screenshots/backup-strategy-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..9969daf144716874aca1304537ff211ae17f5fdf GIT binary patch literal 34330 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 literal 0 HcmV?d00001 diff --git a/README.md b/README.md index ea2d0b4..ad72df2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ First user signup becomes admin. Configure GitHub and Gitea through the web inte - 🔄 **Auto-discovery** - Automatically import new GitHub repositories (v3.4.0+) - 🧹 **Repository cleanup** - Auto-remove repos deleted from GitHub (v3.4.0+) - 🎯 **Proper mirror intervals** - Respects configured sync intervals (v3.4.0+) +- 🛡️ **[Force-push protection](docs/FORCE_PUSH_PROTECTION.md)** - Smart detection with backup-on-demand or block-and-approve modes (Beta) - 🗑️ Automatic database cleanup with configurable retention - 🐳 Dockerized with multi-arch support (AMD64/ARM64) @@ -499,6 +500,7 @@ GNU Affero General Public License v3.0 (AGPL-3.0) - see [LICENSE](LICENSE) file - 📖 [Documentation](https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs) - 🔐 [Environment Variables](docs/ENVIRONMENT_VARIABLES.md) +- 🛡️ [Force-Push Protection](docs/FORCE_PUSH_PROTECTION.md) - 🐛 [Report Issues](https://github.com/RayLabsHQ/gitea-mirror/issues) - 💬 [Discussions](https://github.com/RayLabsHQ/gitea-mirror/discussions) - 🔧 [Proxmox VE Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror) diff --git a/docs/FORCE_PUSH_PROTECTION.md b/docs/FORCE_PUSH_PROTECTION.md new file mode 100644 index 0000000..f15b1d2 --- /dev/null +++ b/docs/FORCE_PUSH_PROTECTION.md @@ -0,0 +1,179 @@ +# 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 `///.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: " \ + -d '{"repositoryIds": [""], "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: " \ + -d '{"repositoryIds": [""], "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. diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index f64ad3f..899a8d8 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -50,7 +50,7 @@ export function ConfigTabs() { starredReposOrg: 'starred', starredReposMode: 'dedicated-org', preserveOrgStructure: false, - backupBeforeSync: true, + backupStrategy: "on-force-push", backupRetentionCount: 20, backupDirectory: 'data/repo-backups', blockSyncOnBackupFailure: true, @@ -660,9 +660,20 @@ export function ConfigTabs() { : update, })) } + giteaConfig={config.giteaConfig} + setGiteaConfig={update => + setConfig(prev => ({ + ...prev, + giteaConfig: + typeof update === 'function' + ? update(prev.giteaConfig) + : update, + })) + } onAutoSave={autoSaveGitHubConfig} onMirrorOptionsAutoSave={autoSaveMirrorOptions} onAdvancedOptionsAutoSave={autoSaveAdvancedOptions} + onGiteaAutoSave={autoSaveGiteaConfig} isAutoSaving={isAutoSavingGitHub} /> >; advancedOptions: AdvancedOptions; setAdvancedOptions: React.Dispatch>; + giteaConfig?: GiteaConfig; + setGiteaConfig?: React.Dispatch>; onAutoSave?: (githubConfig: GitHubConfig) => Promise; onMirrorOptionsAutoSave?: (mirrorOptions: MirrorOptions) => Promise; onAdvancedOptionsAutoSave?: (advancedOptions: AdvancedOptions) => Promise; + onGiteaAutoSave?: (giteaConfig: GiteaConfig) => Promise; isAutoSaving?: boolean; } export function GitHubConfigForm({ - config, - setConfig, + config, + setConfig, mirrorOptions, setMirrorOptions, advancedOptions, setAdvancedOptions, - onAutoSave, + giteaConfig, + setGiteaConfig, + onAutoSave, onMirrorOptionsAutoSave, onAdvancedOptionsAutoSave, - isAutoSaving + onGiteaAutoSave, + isAutoSaving }: GitHubConfigFormProps) { const [isLoading, setIsLoading] = useState(false); @@ -202,7 +209,139 @@ export function GitHubConfigForm({ if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions); }} /> - + + {giteaConfig && setGiteaConfig && ( + <> + + +
+

+ + Destructive Update Protection + BETA +

+

+ Choose how to handle force-pushes or rewritten upstream history on GitHub. +

+ +
+ {([ + { + value: "disabled", + label: "Disabled", + desc: "No detection or backups", + }, + { + value: "always", + label: "Always Backup", + desc: "Snapshot before every sync", + }, + { + value: "on-force-push", + label: "Smart", + desc: "Backup only on force-push", + }, + { + value: "block-on-force-push", + label: "Block & Approve", + desc: "Require approval on force-push", + }, + ] as const).map((opt) => { + const isSelected = (giteaConfig.backupStrategy ?? "on-force-push") === opt.value; + return ( + + ); + })} +
+ + {(giteaConfig.backupStrategy ?? "on-force-push") !== "disabled" && ( + <> +
+
+ + { + const newConfig = { + ...giteaConfig, + backupRetentionCount: Math.max(1, Number.parseInt(e.target.value, 10) || 20), + }; + setGiteaConfig(newConfig); + if (onGiteaAutoSave) onGiteaAutoSave(newConfig); + }} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> +
+
+ + { + const newConfig = { ...giteaConfig, backupDirectory: e.target.value }; + setGiteaConfig(newConfig); + if (onGiteaAutoSave) onGiteaAutoSave(newConfig); + }} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + placeholder="data/repo-backups" + /> +
+
+ + {((giteaConfig.backupStrategy ?? "on-force-push") === "always" || + (giteaConfig.backupStrategy ?? "on-force-push") === "on-force-push") && ( + + )} + + )} +
+ + )} + {/* Mobile: Show button at bottom */} )} - + {repo.status === "pending-approval" && ( +
+ + +
+ )} + {/* Ignore/Include button */} {repo.status === "ignored" ? ( + + + ); + } + // For ignored repos, show an "Include" action if (repo.status === "ignored") { return ( diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 67b3f22..00a0ba5 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -33,6 +33,13 @@ export const githubConfigSchema = z.object({ starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(), }); +export const backupStrategyEnum = z.enum([ + "disabled", + "always", + "on-force-push", + "block-on-force-push", +]); + export const giteaConfigSchema = z.object({ url: z.url(), externalUrl: z.url().optional(), @@ -65,7 +72,8 @@ export const giteaConfigSchema = z.object({ mirrorPullRequests: z.boolean().default(false), mirrorLabels: z.boolean().default(false), mirrorMilestones: z.boolean().default(false), - backupBeforeSync: z.boolean().default(true), + backupStrategy: backupStrategyEnum.default("on-force-push"), + backupBeforeSync: z.boolean().default(true), // Deprecated: kept for backward compat, use backupStrategy backupRetentionCount: z.number().int().min(1).default(20), backupDirectory: z.string().optional(), blockSyncOnBackupFailure: z.boolean().default(true), @@ -165,6 +173,7 @@ export const repositorySchema = z.object({ "syncing", "synced", "archived", + "pending-approval", // Blocked by force-push detection, needs manual approval ]) .default("imported"), lastMirrored: z.coerce.date().optional().nullable(), @@ -196,6 +205,7 @@ export const mirrorJobSchema = z.object({ "syncing", "synced", "archived", + "pending-approval", ]) .default("imported"), message: z.string(), diff --git a/src/lib/gitea-enhanced.ts b/src/lib/gitea-enhanced.ts index 9dc723d..7f133af 100644 --- a/src/lib/gitea-enhanced.ts +++ b/src/lib/gitea-enhanced.ts @@ -19,7 +19,12 @@ import { createPreSyncBundleBackup, shouldCreatePreSyncBackup, shouldBlockSyncOnBackupFailure, + resolveBackupStrategy, + shouldBackupForStrategy, + shouldBlockSyncForStrategy, + strategyNeedsDetection, } from "./repo-backup"; +import { detectForcePush } from "./utils/force-push-detection"; import { parseRepositoryMetadataState, serializeRepositoryMetadataState, @@ -255,9 +260,12 @@ export async function getOrCreateGiteaOrgEnhanced({ export async function syncGiteaRepoEnhanced({ config, repository, + skipForcePushDetection, }: { config: Partial; repository: Repository; + /** When true, skip force-push detection and blocking (used by approve-sync). */ + skipForcePushDetection?: boolean; }, deps?: SyncDependencies): Promise { try { if (!config.userId || !config.giteaConfig?.url || !config.giteaConfig?.token) { @@ -318,58 +326,138 @@ export async function syncGiteaRepoEnhanced({ throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`); } - if (shouldCreatePreSyncBackup(config)) { - const cloneUrl = - repoInfo.clone_url || - `${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`; + // ---- Smart backup strategy with force-push detection ---- + const backupStrategy = resolveBackupStrategy(config); + let forcePushDetected = false; - try { - const backupResult = await createPreSyncBundleBackup({ - config, - owner: repoOwner, - repoName: repository.name, - cloneUrl, - }); + if (backupStrategy !== "disabled") { + // Run force-push detection if the strategy requires it + // (skip when called from approve-sync to avoid re-blocking) + if (strategyNeedsDetection(backupStrategy) && !skipForcePushDetection) { + try { + const decryptedGithubToken = decryptedConfig.githubConfig?.token; + if (decryptedGithubToken) { + const fpOctokit = new Octokit({ auth: decryptedGithubToken }); + const detectionResult = await detectForcePush({ + giteaUrl: config.giteaConfig.url, + giteaToken: decryptedConfig.giteaConfig.token, + giteaOwner: repoOwner, + giteaRepo: repository.name, + octokit: fpOctokit, + githubOwner: repository.owner, + githubRepo: repository.name, + }); - await createMirrorJob({ - userId: config.userId, - repositoryId: repository.id, - repositoryName: repository.name, - message: `Snapshot created for ${repository.name}`, - details: `Pre-sync snapshot created at ${backupResult.bundlePath}.`, - status: "syncing", - }); - } catch (backupError) { - const errorMessage = - backupError instanceof Error ? backupError.message : String(backupError); + forcePushDetected = detectionResult.detected; - await createMirrorJob({ - userId: config.userId, - repositoryId: repository.id, - repositoryName: repository.name, - message: `Snapshot failed for ${repository.name}`, - details: `Pre-sync snapshot failed: ${errorMessage}`, - status: "failed", - }); - - if (shouldBlockSyncOnBackupFailure(config)) { - await db - .update(repositories) - .set({ - status: repoStatusEnum.parse("failed"), - updatedAt: new Date(), - errorMessage: `Snapshot failed; sync blocked to protect history. ${errorMessage}`, - }) - .where(eq(repositories.id, repository.id!)); - - throw new Error( - `Snapshot failed; sync blocked to protect history. ${errorMessage}` + if (detectionResult.skipped) { + console.log( + `[Sync] Force-push detection skipped for ${repository.name}: ${detectionResult.skipReason}`, + ); + } else if (forcePushDetected) { + const branchNames = detectionResult.affectedBranches + .map((b) => `${b.name} (${b.reason})`) + .join(", "); + console.warn( + `[Sync] Force-push detected on ${repository.name}: ${branchNames}`, + ); + } + } else { + console.log( + `[Sync] Skipping force-push detection for ${repository.name}: no GitHub token`, + ); + } + } catch (detectionError) { + // Fail-open: detection errors should never block sync + console.warn( + `[Sync] Force-push detection failed for ${repository.name}, proceeding with sync: ${ + detectionError instanceof Error ? detectionError.message : String(detectionError) + }`, ); } + } - console.warn( - `[Sync] Snapshot failed for ${repository.name}, continuing because blockSyncOnBackupFailure=false: ${errorMessage}` - ); + // Check if sync should be blocked (block-on-force-push mode) + if (shouldBlockSyncForStrategy(backupStrategy, forcePushDetected)) { + const branchInfo = `Force-push detected; sync blocked for manual approval.`; + + await db + .update(repositories) + .set({ + status: "pending-approval", + updatedAt: new Date(), + errorMessage: branchInfo, + }) + .where(eq(repositories.id, repository.id!)); + + await createMirrorJob({ + userId: config.userId, + repositoryId: repository.id, + repositoryName: repository.name, + message: `Sync blocked for ${repository.name}: force-push detected`, + details: branchInfo, + status: "pending-approval", + }); + + console.warn(`[Sync] Sync blocked for ${repository.name}: pending manual approval`); + return { blocked: true, reason: branchInfo }; + } + + // Create backup if strategy says so + if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) { + const cloneUrl = + repoInfo.clone_url || + `${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`; + + try { + const backupResult = await createPreSyncBundleBackup({ + config, + owner: repoOwner, + repoName: repository.name, + cloneUrl, + force: true, // Strategy already decided to backup; skip legacy gate + }); + + await createMirrorJob({ + userId: config.userId, + repositoryId: repository.id, + repositoryName: repository.name, + message: `Snapshot created for ${repository.name}`, + details: `Pre-sync snapshot created at ${backupResult.bundlePath}.`, + status: "syncing", + }); + } catch (backupError) { + const errorMessage = + backupError instanceof Error ? backupError.message : String(backupError); + + await createMirrorJob({ + userId: config.userId, + repositoryId: repository.id, + repositoryName: repository.name, + message: `Snapshot failed for ${repository.name}`, + details: `Pre-sync snapshot failed: ${errorMessage}`, + status: "failed", + }); + + if (shouldBlockSyncOnBackupFailure(config)) { + await db + .update(repositories) + .set({ + status: repoStatusEnum.parse("failed"), + updatedAt: new Date(), + errorMessage: `Snapshot failed; sync blocked to protect history. ${errorMessage}`, + }) + .where(eq(repositories.id, repository.id!)); + + throw new Error( + `Snapshot failed; sync blocked to protect history. ${errorMessage}`, + ); + } + + console.warn( + `[Sync] Snapshot failed for ${repository.name}, continuing because blockSyncOnBackupFailure=false: ${errorMessage}`, + ); + } } } diff --git a/src/lib/repo-backup.test.ts b/src/lib/repo-backup.test.ts index 5d0e498..d491bd6 100644 --- a/src/lib/repo-backup.test.ts +++ b/src/lib/repo-backup.test.ts @@ -1,7 +1,13 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { Config } from "@/types/config"; -import { resolveBackupPaths } from "@/lib/repo-backup"; +import { + resolveBackupPaths, + resolveBackupStrategy, + shouldBackupForStrategy, + shouldBlockSyncForStrategy, + strategyNeedsDetection, +} from "@/lib/repo-backup"; describe("resolveBackupPaths", () => { let originalBackupDirEnv: string | undefined; @@ -113,3 +119,130 @@ describe("resolveBackupPaths", () => { ); }); }); + +// ---- Backup strategy resolver tests ---- + +function makeConfig(overrides: Record = {}): Partial { + return { + giteaConfig: { + url: "https://gitea.example.com", + token: "tok", + ...overrides, + }, + } as Partial; +} + +const envKeysToClean = ["PRE_SYNC_BACKUP_STRATEGY", "PRE_SYNC_BACKUP_ENABLED"]; + +describe("resolveBackupStrategy", () => { + let savedEnv: Record = {}; + + beforeEach(() => { + savedEnv = {}; + for (const key of envKeysToClean) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + test("returns explicit backupStrategy when set", () => { + expect(resolveBackupStrategy(makeConfig({ backupStrategy: "always" }))).toBe("always"); + expect(resolveBackupStrategy(makeConfig({ backupStrategy: "disabled" }))).toBe("disabled"); + expect(resolveBackupStrategy(makeConfig({ backupStrategy: "on-force-push" }))).toBe("on-force-push"); + expect(resolveBackupStrategy(makeConfig({ backupStrategy: "block-on-force-push" }))).toBe("block-on-force-push"); + }); + + test("maps backupBeforeSync: true → 'always' (backward compat)", () => { + expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("always"); + }); + + test("maps backupBeforeSync: false → 'disabled' (backward compat)", () => { + expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: false }))).toBe("disabled"); + }); + + test("prefers explicit backupStrategy over backupBeforeSync", () => { + expect( + resolveBackupStrategy( + makeConfig({ backupStrategy: "on-force-push", backupBeforeSync: true }), + ), + ).toBe("on-force-push"); + }); + + test("falls back to PRE_SYNC_BACKUP_STRATEGY env var", () => { + process.env.PRE_SYNC_BACKUP_STRATEGY = "block-on-force-push"; + expect(resolveBackupStrategy(makeConfig({}))).toBe("block-on-force-push"); + }); + + test("falls back to PRE_SYNC_BACKUP_ENABLED env var (legacy)", () => { + process.env.PRE_SYNC_BACKUP_ENABLED = "false"; + expect(resolveBackupStrategy(makeConfig({}))).toBe("disabled"); + }); + + test("defaults to 'on-force-push' when nothing is configured", () => { + expect(resolveBackupStrategy(makeConfig({}))).toBe("on-force-push"); + }); + + test("handles empty giteaConfig gracefully", () => { + expect(resolveBackupStrategy({})).toBe("on-force-push"); + }); +}); + +describe("shouldBackupForStrategy", () => { + test("disabled → never backup", () => { + expect(shouldBackupForStrategy("disabled", false)).toBe(false); + expect(shouldBackupForStrategy("disabled", true)).toBe(false); + }); + + test("always → always backup", () => { + expect(shouldBackupForStrategy("always", false)).toBe(true); + expect(shouldBackupForStrategy("always", true)).toBe(true); + }); + + test("on-force-push → backup only when detected", () => { + expect(shouldBackupForStrategy("on-force-push", false)).toBe(false); + expect(shouldBackupForStrategy("on-force-push", true)).toBe(true); + }); + + test("block-on-force-push → backup only when detected", () => { + expect(shouldBackupForStrategy("block-on-force-push", false)).toBe(false); + expect(shouldBackupForStrategy("block-on-force-push", true)).toBe(true); + }); +}); + +describe("shouldBlockSyncForStrategy", () => { + test("only block-on-force-push + detected returns true", () => { + expect(shouldBlockSyncForStrategy("block-on-force-push", true)).toBe(true); + }); + + test("block-on-force-push without detection does not block", () => { + expect(shouldBlockSyncForStrategy("block-on-force-push", false)).toBe(false); + }); + + test("other strategies never block", () => { + expect(shouldBlockSyncForStrategy("disabled", true)).toBe(false); + expect(shouldBlockSyncForStrategy("always", true)).toBe(false); + expect(shouldBlockSyncForStrategy("on-force-push", true)).toBe(false); + }); +}); + +describe("strategyNeedsDetection", () => { + test("returns true for detection-based strategies", () => { + expect(strategyNeedsDetection("on-force-push")).toBe(true); + expect(strategyNeedsDetection("block-on-force-push")).toBe(true); + }); + + test("returns false for non-detection strategies", () => { + expect(strategyNeedsDetection("disabled")).toBe(false); + expect(strategyNeedsDetection("always")).toBe(false); + }); +}); diff --git a/src/lib/repo-backup.ts b/src/lib/repo-backup.ts index f84b8bb..0fa4463 100644 --- a/src/lib/repo-backup.ts +++ b/src/lib/repo-backup.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { Config } from "@/types/config"; +import type { Config, BackupStrategy } from "@/types/config"; import { decryptConfigTokens } from "./utils/config-encryption"; const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); @@ -101,6 +101,92 @@ export function shouldBlockSyncOnBackupFailure(config: Partial): boolean return configSetting === undefined ? true : Boolean(configSetting); } +// ---- Backup strategy resolver ---- + +const VALID_STRATEGIES = new Set([ + "disabled", + "always", + "on-force-push", + "block-on-force-push", +]); + +/** + * Resolve the effective backup strategy from config, falling back through: + * 1. `backupStrategy` field (new) + * 2. `backupBeforeSync` boolean (deprecated, backward compat) + * 3. `PRE_SYNC_BACKUP_STRATEGY` env var + * 4. `PRE_SYNC_BACKUP_ENABLED` env var (legacy) + * 5. Default: `"on-force-push"` + */ +export function resolveBackupStrategy(config: Partial): BackupStrategy { + // 1. Explicit backupStrategy field + const explicit = config.giteaConfig?.backupStrategy; + if (explicit && VALID_STRATEGIES.has(explicit as BackupStrategy)) { + return explicit as BackupStrategy; + } + + // 2. Legacy backupBeforeSync boolean → map to strategy + const legacy = config.giteaConfig?.backupBeforeSync; + if (legacy !== undefined) { + return legacy ? "always" : "disabled"; + } + + // 3. Env var (new) + const envStrategy = process.env.PRE_SYNC_BACKUP_STRATEGY?.trim().toLowerCase(); + if (envStrategy && VALID_STRATEGIES.has(envStrategy as BackupStrategy)) { + return envStrategy as BackupStrategy; + } + + // 4. Env var (legacy) + const envEnabled = process.env.PRE_SYNC_BACKUP_ENABLED; + if (envEnabled !== undefined) { + return parseBoolean(envEnabled, true) ? "always" : "disabled"; + } + + // 5. Default + return "on-force-push"; +} + +/** + * Determine whether a backup should be created for the given strategy and + * force-push detection result. + */ +export function shouldBackupForStrategy( + strategy: BackupStrategy, + forcePushDetected: boolean, +): boolean { + switch (strategy) { + case "disabled": + return false; + case "always": + return true; + case "on-force-push": + case "block-on-force-push": + return forcePushDetected; + default: + return false; + } +} + +/** + * Determine whether sync should be blocked (requires manual approval). + * Only `block-on-force-push` with an actual detection blocks sync. + */ +export function shouldBlockSyncForStrategy( + strategy: BackupStrategy, + forcePushDetected: boolean, +): boolean { + return strategy === "block-on-force-push" && forcePushDetected; +} + +/** + * Returns true when the strategy requires running force-push detection + * before deciding on backup / block behavior. + */ +export function strategyNeedsDetection(strategy: BackupStrategy): boolean { + return strategy === "on-force-push" || strategy === "block-on-force-push"; +} + export function resolveBackupPaths({ config, owner, @@ -136,13 +222,17 @@ export async function createPreSyncBundleBackup({ owner, repoName, cloneUrl, + force, }: { config: Partial; owner: string; repoName: string; cloneUrl: string; + /** When true, skip the legacy shouldCreatePreSyncBackup check. + * Used by the strategy-driven path which has already decided to backup. */ + force?: boolean; }): Promise<{ bundlePath: string }> { - if (!shouldCreatePreSyncBackup(config)) { + if (!force && !shouldCreatePreSyncBackup(config)) { throw new Error("Pre-sync backup is disabled."); } diff --git a/src/lib/scheduler-service.ts b/src/lib/scheduler-service.ts index 8fe45d2..8e0413d 100644 --- a/src/lib/scheduler-service.ts +++ b/src/lib/scheduler-service.ts @@ -280,11 +280,29 @@ async function runScheduledSync(config: any): Promise { }); } + // Log pending-approval repos that are excluded from sync + try { + const pendingApprovalRepos = await db + .select({ id: repositories.id }) + .from(repositories) + .where( + and( + eq(repositories.userId, userId), + eq(repositories.status, 'pending-approval') + ) + ); + if (pendingApprovalRepos.length > 0) { + console.log(`[Scheduler] ${pendingApprovalRepos.length} repositories pending approval (force-push detected) for user ${userId} — skipping sync for those`); + } + } catch { + // Non-critical logging, ignore errors + } + if (reposToSync.length === 0) { console.log(`[Scheduler] No repositories to sync for user ${userId}`); return; } - + console.log(`[Scheduler] Syncing ${reposToSync.length} repositories for user ${userId}`); // Process repositories in batches diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 77e27c0..c47fb36 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -280,6 +280,8 @@ export const getStatusColor = (status: string): string => { return "bg-orange-500"; // Deleting case "deleted": return "bg-gray-600"; // Deleted + case "pending-approval": + return "bg-amber-500"; // Needs manual approval default: return "bg-gray-400"; // Unknown/neutral } diff --git a/src/lib/utils/config-defaults.ts b/src/lib/utils/config-defaults.ts index 32693b3..9c95173 100644 --- a/src/lib/utils/config-defaults.ts +++ b/src/lib/utils/config-defaults.ts @@ -93,7 +93,8 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default forkStrategy: "reference", issueConcurrency: 3, pullRequestConcurrency: 5, - backupBeforeSync: true, + backupStrategy: "on-force-push", + backupBeforeSync: true, // Deprecated: kept for backward compat backupRetentionCount: 20, backupDirectory: "data/repo-backups", blockSyncOnBackupFailure: true, diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts index 422bbbd..4c2779f 100644 --- a/src/lib/utils/config-mapper.ts +++ b/src/lib/utils/config-mapper.ts @@ -100,6 +100,7 @@ export function mapUiToDbConfig( mirrorPullRequests: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests, mirrorLabels: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.labels, mirrorMilestones: mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.milestones, + backupStrategy: giteaConfig.backupStrategy, backupBeforeSync: giteaConfig.backupBeforeSync ?? true, backupRetentionCount: giteaConfig.backupRetentionCount ?? 20, backupDirectory: giteaConfig.backupDirectory?.trim() || undefined, @@ -144,6 +145,7 @@ export function mapDbToUiConfig(dbConfig: any): { personalReposOrg: undefined, // Not stored in current schema issueConcurrency: dbConfig.giteaConfig?.issueConcurrency ?? 3, pullRequestConcurrency: dbConfig.giteaConfig?.pullRequestConcurrency ?? 5, + backupStrategy: dbConfig.giteaConfig?.backupStrategy || undefined, backupBeforeSync: dbConfig.giteaConfig?.backupBeforeSync ?? true, backupRetentionCount: dbConfig.giteaConfig?.backupRetentionCount ?? 20, backupDirectory: dbConfig.giteaConfig?.backupDirectory || "data/repo-backups", diff --git a/src/lib/utils/force-push-detection.test.ts b/src/lib/utils/force-push-detection.test.ts new file mode 100644 index 0000000..5a686c6 --- /dev/null +++ b/src/lib/utils/force-push-detection.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + detectForcePush, + fetchGitHubBranches, + checkAncestry, + type BranchInfo, +} from "./force-push-detection"; + +// ---- Helpers ---- + +function makeOctokit(overrides: Record = {}) { + return { + repos: { + listBranches: mock(() => Promise.resolve({ data: [] })), + compareCommits: mock(() => + Promise.resolve({ data: { status: "ahead" } }), + ), + ...overrides.repos, + }, + paginate: mock(async (_method: any, params: any) => { + // Default: return whatever the test wired into _githubBranches + return overrides._githubBranches ?? []; + }), + ...overrides, + } as any; +} + +// ---- fetchGitHubBranches ---- + +describe("fetchGitHubBranches", () => { + it("maps Octokit paginated response to BranchInfo[]", async () => { + const octokit = makeOctokit({ + _githubBranches: [ + { name: "main", commit: { sha: "aaa" } }, + { name: "dev", commit: { sha: "bbb" } }, + ], + }); + + const result = await fetchGitHubBranches({ + octokit, + owner: "user", + repo: "repo", + }); + + expect(result).toEqual([ + { name: "main", sha: "aaa" }, + { name: "dev", sha: "bbb" }, + ]); + }); +}); + +// ---- checkAncestry ---- + +describe("checkAncestry", () => { + it("returns true for fast-forward (ahead)", async () => { + const octokit = makeOctokit({ + repos: { + compareCommits: mock(() => + Promise.resolve({ data: { status: "ahead" } }), + ), + }, + }); + + const result = await checkAncestry({ + octokit, + owner: "user", + repo: "repo", + baseSha: "old", + headSha: "new", + }); + + expect(result).toBe(true); + }); + + it("returns true for identical", async () => { + const octokit = makeOctokit({ + repos: { + compareCommits: mock(() => + Promise.resolve({ data: { status: "identical" } }), + ), + }, + }); + + const result = await checkAncestry({ + octokit, + owner: "user", + repo: "repo", + baseSha: "same", + headSha: "same", + }); + + expect(result).toBe(true); + }); + + it("returns false for diverged", async () => { + const octokit = makeOctokit({ + repos: { + compareCommits: mock(() => + Promise.resolve({ data: { status: "diverged" } }), + ), + }, + }); + + const result = await checkAncestry({ + octokit, + owner: "user", + repo: "repo", + baseSha: "old", + headSha: "new", + }); + + expect(result).toBe(false); + }); + + it("returns false when API returns 404 (old SHA gone)", async () => { + const error404 = Object.assign(new Error("Not Found"), { status: 404 }); + const octokit = makeOctokit({ + repos: { + compareCommits: mock(() => Promise.reject(error404)), + }, + }); + + const result = await checkAncestry({ + octokit, + owner: "user", + repo: "repo", + baseSha: "gone", + headSha: "new", + }); + + expect(result).toBe(false); + }); + + it("throws on transient errors (fail-open for caller)", async () => { + const error500 = Object.assign(new Error("Internal Server Error"), { status: 500 }); + const octokit = makeOctokit({ + repos: { + compareCommits: mock(() => Promise.reject(error500)), + }, + }); + + expect( + checkAncestry({ + octokit, + owner: "user", + repo: "repo", + baseSha: "old", + headSha: "new", + }), + ).rejects.toThrow("Internal Server Error"); + }); +}); + +// ---- detectForcePush ---- +// Uses _deps injection to avoid fragile global fetch mocking. + +describe("detectForcePush", () => { + const baseArgs = { + giteaUrl: "https://gitea.example.com", + giteaToken: "tok", + giteaOwner: "org", + giteaRepo: "repo", + githubOwner: "user", + githubRepo: "repo", + }; + + function makeDeps(overrides: { + giteaBranches?: BranchInfo[] | Error; + githubBranches?: BranchInfo[] | Error; + ancestryResult?: boolean; + } = {}) { + return { + fetchGiteaBranches: mock(async () => { + if (overrides.giteaBranches instanceof Error) throw overrides.giteaBranches; + return overrides.giteaBranches ?? []; + }) as any, + fetchGitHubBranches: mock(async () => { + if (overrides.githubBranches instanceof Error) throw overrides.githubBranches; + return overrides.githubBranches ?? []; + }) as any, + checkAncestry: mock(async () => overrides.ancestryResult ?? true) as any, + }; + } + + const dummyOctokit = {} as any; + + it("skips when Gitea has no branches (first mirror)", async () => { + const deps = makeDeps({ giteaBranches: [] }); + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(false); + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain("No Gitea branches"); + }); + + it("returns no detection when all SHAs match", async () => { + const deps = makeDeps({ + giteaBranches: [ + { name: "main", sha: "aaa" }, + { name: "dev", sha: "bbb" }, + ], + githubBranches: [ + { name: "main", sha: "aaa" }, + { name: "dev", sha: "bbb" }, + ], + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(false); + expect(result.skipped).toBe(false); + expect(result.affectedBranches).toHaveLength(0); + }); + + it("detects deleted branch", async () => { + const deps = makeDeps({ + giteaBranches: [ + { name: "main", sha: "aaa" }, + { name: "old-branch", sha: "ccc" }, + ], + githubBranches: [{ name: "main", sha: "aaa" }], + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(true); + expect(result.affectedBranches).toHaveLength(1); + expect(result.affectedBranches[0]).toEqual({ + name: "old-branch", + reason: "deleted", + giteaSha: "ccc", + githubSha: null, + }); + }); + + it("returns no detection for fast-forward", async () => { + const deps = makeDeps({ + giteaBranches: [{ name: "main", sha: "old-sha" }], + githubBranches: [{ name: "main", sha: "new-sha" }], + ancestryResult: true, // fast-forward + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(false); + expect(result.affectedBranches).toHaveLength(0); + }); + + it("detects diverged branch", async () => { + const deps = makeDeps({ + giteaBranches: [{ name: "main", sha: "old-sha" }], + githubBranches: [{ name: "main", sha: "rewritten-sha" }], + ancestryResult: false, // diverged + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(true); + expect(result.affectedBranches).toHaveLength(1); + expect(result.affectedBranches[0]).toEqual({ + name: "main", + reason: "diverged", + giteaSha: "old-sha", + githubSha: "rewritten-sha", + }); + }); + + it("detects force-push when ancestry check fails (old SHA gone)", async () => { + const deps = makeDeps({ + giteaBranches: [{ name: "main", sha: "old-sha" }], + githubBranches: [{ name: "main", sha: "new-sha" }], + ancestryResult: false, // checkAncestry returns false on error + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(true); + expect(result.affectedBranches).toHaveLength(1); + expect(result.affectedBranches[0].reason).toBe("diverged"); + }); + + it("skips when Gitea API returns 404", async () => { + const { HttpError } = await import("@/lib/http-client"); + const deps = makeDeps({ + giteaBranches: new HttpError("not found", 404, "Not Found"), + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(false); + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain("not found"); + }); + + it("skips when Gitea API returns server error", async () => { + const deps = makeDeps({ + giteaBranches: new Error("HTTP 500: internal error"), + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(false); + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain("Failed to fetch Gitea branches"); + }); + + it("skips when GitHub API fails", async () => { + const deps = makeDeps({ + giteaBranches: [{ name: "main", sha: "aaa" }], + githubBranches: new Error("rate limited"), + }); + + const result = await detectForcePush({ ...baseArgs, octokit: dummyOctokit, _deps: deps }); + + expect(result.detected).toBe(false); + expect(result.skipped).toBe(true); + expect(result.skipReason).toContain("Failed to fetch GitHub branches"); + }); +}); diff --git a/src/lib/utils/force-push-detection.ts b/src/lib/utils/force-push-detection.ts new file mode 100644 index 0000000..a6f65df --- /dev/null +++ b/src/lib/utils/force-push-detection.ts @@ -0,0 +1,286 @@ +/** + * Force-push detection module. + * + * Compares branch SHAs between a Gitea mirror and GitHub source to detect + * branches that were deleted, rewritten, or force-pushed. + * + * **Fail-open**: If detection itself fails (API errors, rate limits, etc.), + * the result indicates no force-push so sync proceeds normally. Detection + * should never block sync due to its own failure. + */ + +import type { Octokit } from "@octokit/rest"; +import { httpGet, HttpError } from "@/lib/http-client"; + +// ---- Types ---- + +export interface BranchInfo { + name: string; + sha: string; +} + +export type ForcePushReason = "deleted" | "diverged" | "non-fast-forward"; + +export interface AffectedBranch { + name: string; + reason: ForcePushReason; + giteaSha: string; + githubSha: string | null; // null when branch was deleted +} + +export interface ForcePushDetectionResult { + detected: boolean; + affectedBranches: AffectedBranch[]; + /** True when detection could not run (API error, etc.) */ + skipped: boolean; + skipReason?: string; +} + +const NO_FORCE_PUSH: ForcePushDetectionResult = { + detected: false, + affectedBranches: [], + skipped: false, +}; + +function skippedResult(reason: string): ForcePushDetectionResult { + return { + detected: false, + affectedBranches: [], + skipped: true, + skipReason: reason, + }; +} + +// ---- Branch fetching ---- + +/** + * Fetch all branches from a Gitea repository (paginated). + */ +export async function fetchGiteaBranches({ + giteaUrl, + giteaToken, + owner, + repo, +}: { + giteaUrl: string; + giteaToken: string; + owner: string; + repo: string; +}): Promise { + const branches: BranchInfo[] = []; + let page = 1; + const perPage = 50; + + while (true) { + const url = `${giteaUrl}/api/v1/repos/${owner}/${repo}/branches?page=${page}&limit=${perPage}`; + const response = await httpGet>( + url, + { Authorization: `token ${giteaToken}` }, + ); + + if (!Array.isArray(response.data) || response.data.length === 0) break; + + for (const b of response.data) { + branches.push({ name: b.name, sha: b.commit.id }); + } + + if (response.data.length < perPage) break; + page++; + } + + return branches; +} + +/** + * Fetch all branches from a GitHub repository (paginated via Octokit). + */ +export async function fetchGitHubBranches({ + octokit, + owner, + repo, +}: { + octokit: Octokit; + owner: string; + repo: string; +}): Promise { + const data = await octokit.paginate(octokit.repos.listBranches, { + owner, + repo, + per_page: 100, + }); + + return data.map((b) => ({ name: b.name, sha: b.commit.sha })); +} + +/** + * Check whether the transition from `baseSha` to `headSha` on the same branch + * is a fast-forward (i.e. `baseSha` is an ancestor of `headSha`). + * + * Returns `true` when the change is safe (fast-forward) and `false` when it + * is a confirmed force-push (404 = old SHA garbage-collected from GitHub). + * + * Throws on transient errors (rate limits, network issues) so the caller + * can decide how to handle them (fail-open: skip that branch). + */ +export async function checkAncestry({ + octokit, + owner, + repo, + baseSha, + headSha, +}: { + octokit: Octokit; + owner: string; + repo: string; + baseSha: string; + headSha: string; +}): Promise { + try { + const { data } = await octokit.repos.compareCommits({ + owner, + repo, + base: baseSha, + head: headSha, + }); + // "ahead" means headSha is strictly ahead of baseSha → fast-forward. + // "behind" or "diverged" means the branch was rewritten. + return data.status === "ahead" || data.status === "identical"; + } catch (error: any) { + // 404 / 422 = old SHA no longer exists on GitHub → confirmed force-push. + if (error?.status === 404 || error?.status === 422) { + return false; + } + // Any other error (rate limit, network) → rethrow so caller can + // handle it as fail-open (skip branch) rather than false-positive. + throw error; + } +} + +// ---- Main detection ---- + +/** + * Compare branch SHAs between Gitea and GitHub to detect force-pushes. + * + * The function is intentionally fail-open: any error during detection returns + * a "skipped" result so that sync can proceed normally. + */ +export async function detectForcePush({ + giteaUrl, + giteaToken, + giteaOwner, + giteaRepo, + octokit, + githubOwner, + githubRepo, + _deps, +}: { + giteaUrl: string; + giteaToken: string; + giteaOwner: string; + giteaRepo: string; + octokit: Octokit; + githubOwner: string; + githubRepo: string; + /** @internal — test-only dependency injection */ + _deps?: { + fetchGiteaBranches: typeof fetchGiteaBranches; + fetchGitHubBranches: typeof fetchGitHubBranches; + checkAncestry: typeof checkAncestry; + }; +}): Promise { + const deps = _deps ?? { fetchGiteaBranches, fetchGitHubBranches, checkAncestry }; + + // 1. Fetch Gitea branches + let giteaBranches: BranchInfo[]; + try { + giteaBranches = await deps.fetchGiteaBranches({ + giteaUrl, + giteaToken, + owner: giteaOwner, + repo: giteaRepo, + }); + } catch (error) { + // Gitea 404 = repo not yet mirrored, skip detection + if (error instanceof HttpError && error.status === 404) { + return skippedResult("Gitea repository not found (first mirror?)"); + } + return skippedResult( + `Failed to fetch Gitea branches: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // First-time mirror: no Gitea branches → nothing to compare + if (giteaBranches.length === 0) { + return skippedResult("No Gitea branches found (first mirror?)"); + } + + // 2. Fetch GitHub branches + let githubBranches: BranchInfo[]; + try { + githubBranches = await deps.fetchGitHubBranches({ + octokit, + owner: githubOwner, + repo: githubRepo, + }); + } catch (error) { + return skippedResult( + `Failed to fetch GitHub branches: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + const githubBranchMap = new Map(githubBranches.map((b) => [b.name, b.sha])); + + // 3. Compare each Gitea branch against GitHub + const affected: AffectedBranch[] = []; + + for (const giteaBranch of giteaBranches) { + const githubSha = githubBranchMap.get(giteaBranch.name); + + if (githubSha === undefined) { + // Branch was deleted on GitHub + affected.push({ + name: giteaBranch.name, + reason: "deleted", + giteaSha: giteaBranch.sha, + githubSha: null, + }); + continue; + } + + // Same SHA → no change + if (githubSha === giteaBranch.sha) continue; + + // SHAs differ → check if it's a fast-forward + try { + const isFastForward = await deps.checkAncestry({ + octokit, + owner: githubOwner, + repo: githubRepo, + baseSha: giteaBranch.sha, + headSha: githubSha, + }); + + if (!isFastForward) { + affected.push({ + name: giteaBranch.name, + reason: "diverged", + giteaSha: giteaBranch.sha, + githubSha, + }); + } + } catch { + // Individual branch check failure → skip that branch (fail-open) + continue; + } + } + + if (affected.length === 0) { + return NO_FORCE_PUSH; + } + + return { + detected: true, + affectedBranches: affected, + skipped: false, + }; +} diff --git a/src/pages/api/job/approve-sync.ts b/src/pages/api/job/approve-sync.ts new file mode 100644 index 0000000..14cec9b --- /dev/null +++ b/src/pages/api/job/approve-sync.ts @@ -0,0 +1,202 @@ +import type { APIRoute } from "astro"; +import { db, configs, repositories } from "@/lib/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository"; +import { syncGiteaRepoEnhanced } from "@/lib/gitea-enhanced"; +import { createSecureErrorResponse } from "@/lib/utils"; +import { requireAuthenticatedUserId } from "@/lib/auth-guards"; +import { createPreSyncBundleBackup } from "@/lib/repo-backup"; +import { decryptConfigTokens } from "@/lib/utils/config-encryption"; +import type { Config } from "@/types/config"; +import { createMirrorJob } from "@/lib/helpers"; + +interface ApproveSyncRequest { + repositoryIds: string[]; + action: "approve" | "dismiss"; +} + +export const POST: APIRoute = async ({ request, locals }) => { + try { + const authResult = await requireAuthenticatedUserId({ request, locals }); + if ("response" in authResult) return authResult.response; + const userId = authResult.userId; + + const body: ApproveSyncRequest = await request.json(); + const { repositoryIds, action } = body; + + if (!repositoryIds || !Array.isArray(repositoryIds) || repositoryIds.length === 0) { + return new Response( + JSON.stringify({ success: false, message: "repositoryIds are required." }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + if (action !== "approve" && action !== "dismiss") { + return new Response( + JSON.stringify({ success: false, message: "action must be 'approve' or 'dismiss'." }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + // Fetch config + const configResult = await db + .select() + .from(configs) + .where(eq(configs.userId, userId)) + .limit(1); + + const config = configResult[0]; + if (!config) { + return new Response( + JSON.stringify({ success: false, message: "No configuration found." }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + // Fetch repos — only those in pending-approval status + const repos = await db + .select() + .from(repositories) + .where( + and( + eq(repositories.userId, userId), + eq(repositories.status, "pending-approval"), + inArray(repositories.id, repositoryIds), + ), + ); + + if (!repos.length) { + return new Response( + JSON.stringify({ success: false, message: "No pending-approval repositories found for the given IDs." }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + + if (action === "dismiss") { + // Reset status to "synced" so repos resume normal schedule + for (const repo of repos) { + await db + .update(repositories) + .set({ + status: "synced", + errorMessage: null, + updatedAt: new Date(), + }) + .where(eq(repositories.id, repo.id)); + + await createMirrorJob({ + userId, + repositoryId: repo.id, + repositoryName: repo.name, + message: `Force-push alert dismissed for ${repo.name}`, + details: "User dismissed the force-push alert. Repository will resume normal sync schedule.", + status: "synced", + }); + } + + return new Response( + JSON.stringify({ + success: true, + message: `Dismissed ${repos.length} repository alert(s).`, + repositories: repos.map((repo) => ({ + ...repo, + status: "synced", + errorMessage: null, + })), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + // action === "approve": create backup first (safety), then trigger sync + const decryptedConfig = decryptConfigTokens(config as unknown as Config); + + // Process in background + setTimeout(async () => { + for (const repo of repos) { + try { + const { getGiteaRepoOwnerAsync } = await import("@/lib/gitea"); + const repoOwner = await getGiteaRepoOwnerAsync({ config, repository: repo }); + + // Always create a backup before approved sync for safety + const cloneUrl = `${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repo.name}.git`; + try { + const backupResult = await createPreSyncBundleBackup({ + config, + owner: repoOwner, + repoName: repo.name, + cloneUrl, + force: true, // Bypass legacy gate — approval implies backup + }); + + await createMirrorJob({ + userId, + repositoryId: repo.id, + repositoryName: repo.name, + message: `Safety snapshot created for ${repo.name}`, + details: `Pre-approval snapshot at ${backupResult.bundlePath}.`, + status: "syncing", + }); + } catch (backupError) { + console.warn( + `[ApproveSync] Backup failed for ${repo.name}, proceeding with sync: ${ + backupError instanceof Error ? backupError.message : String(backupError) + }`, + ); + } + + // Trigger sync — skip detection to avoid re-blocking + const repoData = { + ...repo, + status: repoStatusEnum.parse("syncing"), + organization: repo.organization ?? undefined, + lastMirrored: repo.lastMirrored ?? undefined, + errorMessage: repo.errorMessage ?? undefined, + forkedFrom: repo.forkedFrom ?? undefined, + visibility: repositoryVisibilityEnum.parse(repo.visibility), + mirroredLocation: repo.mirroredLocation || "", + }; + + await syncGiteaRepoEnhanced({ + config, + repository: repoData, + skipForcePushDetection: true, + }); + console.log(`[ApproveSync] Sync completed for approved repository: ${repo.name}`); + } catch (error) { + console.error( + `[ApproveSync] Failed to sync approved repository ${repo.name}:`, + error, + ); + } + } + }, 0); + + // Immediately update status to syncing for responsiveness + for (const repo of repos) { + await db + .update(repositories) + .set({ + status: "syncing", + errorMessage: null, + updatedAt: new Date(), + }) + .where(eq(repositories.id, repo.id)); + } + + return new Response( + JSON.stringify({ + success: true, + message: `Approved sync for ${repos.length} repository(ies). Backup + sync started.`, + repositories: repos.map((repo) => ({ + ...repo, + status: "syncing", + errorMessage: null, + })), + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } catch (error) { + return createSecureErrorResponse(error, "approve-sync", 500); + } +}; diff --git a/src/types/Repository.ts b/src/types/Repository.ts index 30cc169..9272c3b 100644 --- a/src/types/Repository.ts +++ b/src/types/Repository.ts @@ -13,6 +13,7 @@ export const repoStatusEnum = z.enum([ "syncing", "synced", "archived", + "pending-approval", // Blocked by force-push detection, needs manual approval ]); export type RepoStatus = z.infer; diff --git a/src/types/config.ts b/src/types/config.ts index 3101316..ca25e5d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,7 @@ import { type Config as ConfigType } from "@/lib/db/schema"; export type GiteaOrgVisibility = "public" | "private" | "limited"; export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed"; export type StarredReposMode = "dedicated-org" | "preserve-owner"; +export type BackupStrategy = "disabled" | "always" | "on-force-push" | "block-on-force-push"; export interface GiteaConfig { url: string; @@ -18,7 +19,8 @@ export interface GiteaConfig { personalReposOrg?: string; // Override destination for personal repos issueConcurrency?: number; pullRequestConcurrency?: number; - backupBeforeSync?: boolean; + backupStrategy?: BackupStrategy; + backupBeforeSync?: boolean; // Deprecated: kept for backward compat, use backupStrategy backupRetentionCount?: number; backupDirectory?: string; blockSyncOnBackupFailure?: boolean; diff --git a/tests/e2e/03-backup.spec.ts b/tests/e2e/03-backup.spec.ts index 35a5c5e..ca6f1df 100644 --- a/tests/e2e/03-backup.spec.ts +++ b/tests/e2e/03-backup.spec.ts @@ -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"); diff --git a/tests/e2e/04-force-push.spec.ts b/tests/e2e/04-force-push.spec.ts index c974e0d..c1a850f 100644 --- a/tests/e2e/04-force-push.spec.ts +++ b/tests/e2e/04-force-push.spec.ts @@ -302,7 +302,7 @@ test.describe("E2E: Force-push simulation", () => { // Ensure backup is disabled for this test await saveConfig(request, giteaToken, appCookies, { giteaConfig: { - backupBeforeSync: false, + backupStrategy: "disabled", blockSyncOnBackupFailure: false, }, }); @@ -560,16 +560,16 @@ test.describe("E2E: Force-push simulation", () => { const giteaToken = giteaApi.getTokenValue(); - // Enable backup + // Enable backup with "always" strategy await saveConfig(request, giteaToken, appCookies, { giteaConfig: { - backupBeforeSync: true, + backupStrategy: "always", blockSyncOnBackupFailure: false, // don't block — we want to see both backup + sync happen backupRetentionCount: 5, backupDirectory: "data/repo-backups", }, }); - console.log("[ForcePush] Backup enabled for protected sync test"); + console.log("[ForcePush] Backup enabled (strategy=always) for protected sync test"); // Force-push again mutateSourceRepo(MY_PROJECT_BARE, "my-project-rewrite2", (workDir) => { @@ -744,7 +744,7 @@ test.describe("E2E: Force-push simulation", () => { expect( backupJobs.length, "At least one backup/snapshot activity should exist for my-project " + - "when backupBeforeSync is enabled", + "when backupStrategy is 'always'", ).toBeGreaterThan(0); // Check whether any backups actually succeeded diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 1a6d310..3fc2bb3 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -520,7 +520,7 @@ export async function saveConfig( starredReposOrg: "github-stars", preserveOrgStructure: false, mirrorStrategy: "single-org", - backupBeforeSync: false, + backupStrategy: "disabled", blockSyncOnBackupFailure: false, };