Compare commits

...

6 Commits

Author SHA1 Message Date
Arunavo Ray b1daa65228 chore: bump version to 3.16.2 2026-05-26 11:09:31 +05:30
Sean Mousseau dd1c42264e fix: stop snapshot-row zombies + flapping force-push on deleted branches (#300)
* fix: stop snapshot-row zombies + flapping force-push on deleted branches

Two bugs caused Simple-WP-Helpdesk to accumulate one orphan
"Snapshot created" job row per scheduled sync — 7 zombies in 24h
against a deleted GitHub branch (`fix/v4.0.2-webhook-comment-author`).

(1) gitea-enhanced.ts:522 created the post-snapshot job record with
status="syncing". The snapshot was already complete at that point
(createPreSyncBundleBackup had returned), and no later code path
advanced the row to a terminal status — it just lingered. Set
status="synced" to reflect reality.

(2) force-push-detection treated any Gitea branch missing from GitHub
as a "deleted" force-push. But gitea-mirror is one-way (GitHub →
Gitea), so deletions never propagate back to the Gitea mirror —
the branch lingers in Gitea forever, the missing-from-GitHub condition
holds on every subsequent sync, and detection re-fires endlessly.
Each re-fire triggered backupBeforeSync=true to take a fresh snapshot
of the exact same state we already backed up the previous cycle.

Fix: detector accepts an `acknowledgedDeletions: { branch, giteaSha }[]`
list (persisted in RepositoryMetadataState). A Gitea branch missing
from GitHub is suppressed when its current giteaSha matches an entry.
If the giteaSha later changes (branch restored, then re-deleted with
new history), the entry won't match and detection fires again — back
up the new state, then acknowledge the new SHA. After a successful
snapshot, the call site appends each just-backed-up "deleted" branch
to the acknowledged list and persists the metadata blob.

Hoists parseRepositoryMetadataState above the backup-strategy block so
both the detection-time read and post-snapshot append happen against
the same in-memory state object; the existing metadata-mirror block
just stops re-declaring it.

Adds 9 new test cases covering:
  - deleted branch suppressed when acknowledged at matching giteaSha
  - re-flagged when giteaSha differs (restored-then-redeleted)
  - mixed deletions (only matching one suppressed)
  - undefined list = back-compat (existing callers unaffected)
  - acknowledgedDeletions does not suppress "diverged"
  - metadata-state parse/serialize round-trips the new field
  - legacy metadata defaults acknowledgedDeletions to []
  - malformed entries dropped without throwing

All 76 tests in the affected suites pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: acknowledge deletion before backup, not after, to fix concurrent-sync race

When two sync invocations fire for the same repo within the same backup
window (observed in prod via UI trigger pipeline: a single click ends up
producing two parallel processWithResilience callbacks that both run
syncGiteaRepoEnhanced), the second invocation's createPreSyncBundleBackup
short-circuits (file already exists from the first), so the second
invocation never reached the acknowledge-push that lived inside the
backup try block. Then both invocations wrote metadata: invocation A
with acknowledgedDeletions=[forged], invocation B (with its stale
in-memory state) with acknowledgedDeletions=[], and B's write landed
last — overwriting A.

Net result: the deletion never got acknowledged, and the next sync
re-detected it, the next-next sync re-detected it, etc. Same flapping
behavior as before the fix.

Fix: move the acknowledge-push to run right after detection, based on
detectionResult.affectedBranches alone. Semantically correct — once
we've detected the branch is gone from GitHub, we know it's a permanent
deletion; whether THIS particular invocation took a backup or
short-circuited is orthogonal (a previous backup on disk is fine, and
in the rare case where no backup ever succeeded, the user can re-trigger
a manual backup). Both concurrent invocations now push the same entry;
both write the same metadata; race is benign.

Verified locally: 59 tests pass across force-push-detection,
gitea-enhanced, repo-backup.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 11:06:27 +05:30
Arunavo Ray 384fbbbe10 docs: document Header / Forward Authentication setup
Header auth has been a working feature since v2.x but was missing from
SSO-OIDC-SETUP.md, leading users to think it was dropped in the v3
rewrite (see #29). Adds a dedicated section covering env-var config,
Authentik + Authelia examples, lookup order, verification, and the
must-strip-inbound-headers security checklist.
2026-05-25 10:37:39 +05:30
Sean Mousseau 1a54010950 fix: prevent duplicate milestones & labels on every sync (#299)
Paginate /milestones and /labels with Link header + X-Total-Count fallback, and pass state=all on milestones so closed milestones aren't re-POSTed on every sync. Caches newly-created items in the in-memory dedup set.
2026-05-25 08:28:40 +05:30
CrumblyLiquid 9bf22791a9 Regenerated bun.nix (#298) 2026-05-25 08:26:19 +05:30
Arunavo Ray 5d82f22b12 chore: sync version to 3.16.1 2026-05-23 20:25:16 +05:30
9 changed files with 707 additions and 63 deletions
+39 -35
View File
@@ -1409,9 +1409,9 @@
url = "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz";
hash = "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==";
};
"@xmldom/xmldom@0.8.11" = fetchurl {
url = "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz";
hash = "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==";
"@xmldom/xmldom@0.8.13" = fetchurl {
url = "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz";
hash = "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==";
};
"acorn-jsx@5.3.2" = fetchurl {
url = "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz";
@@ -1701,9 +1701,9 @@
url = "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz";
hash = "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==";
};
"defu@6.1.4" = fetchurl {
url = "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz";
hash = "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==";
"defu@6.1.7" = fetchurl {
url = "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz";
hash = "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==";
};
"depd@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz";
@@ -1725,9 +1725,9 @@
url = "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz";
hash = "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==";
};
"devalue@5.6.4" = fetchurl {
url = "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz";
hash = "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==";
"devalue@5.8.1" = fetchurl {
url = "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz";
hash = "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==";
};
"devlop@1.1.0" = fetchurl {
url = "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz";
@@ -1773,9 +1773,9 @@
url = "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz";
hash = "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==";
};
"drizzle-orm@0.45.1" = fetchurl {
url = "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz";
hash = "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==";
"drizzle-orm@0.45.2" = fetchurl {
url = "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz";
hash = "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==";
};
"dset@3.1.4" = fetchurl {
url = "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz";
@@ -1909,17 +1909,17 @@
url = "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz";
hash = "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==";
};
"fast-uri@3.1.0" = fetchurl {
url = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz";
hash = "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==";
"fast-uri@3.1.2" = fetchurl {
url = "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz";
hash = "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==";
};
"fast-xml-builder@1.1.3" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz";
hash = "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==";
"fast-xml-builder@1.2.0" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz";
hash = "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==";
};
"fast-xml-parser@5.5.5" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz";
hash = "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==";
"fast-xml-parser@5.5.6" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz";
hash = "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==";
};
"fdir@6.5.0" = fetchurl {
url = "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz";
@@ -2177,9 +2177,9 @@
url = "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz";
hash = "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==";
};
"kysely@0.28.12" = fetchurl {
url = "https://registry.npmjs.org/kysely/-/kysely-0.28.12.tgz";
hash = "sha512-kWiueDWXhbCchgiotwXkwdxZE/6h56IHAeFWg4euUfW0YsmO9sxbAxzx1KLLv2lox15EfuuxHQvgJ1qIfZuHGw==";
"kysely@0.28.17" = fetchurl {
url = "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz";
hash = "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==";
};
"lightningcss-android-arm64@1.31.1" = fetchurl {
url = "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz";
@@ -2305,9 +2305,9 @@
url = "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz";
hash = "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==";
};
"lodash@4.17.21" = fetchurl {
url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
hash = "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==";
"lodash@4.18.1" = fetchurl {
url = "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz";
hash = "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==";
};
"longest-streak@3.1.0" = fetchurl {
url = "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz";
@@ -2709,6 +2709,10 @@
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz";
hash = "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==";
};
"path-expression-matcher@1.5.0" = fetchurl {
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz";
hash = "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==";
};
"pathe@2.0.3" = fetchurl {
url = "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz";
hash = "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==";
@@ -2721,13 +2725,9 @@
url = "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz";
hash = "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==";
};
"picomatch@2.3.1" = fetchurl {
url = "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz";
hash = "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==";
};
"picomatch@4.0.3" = fetchurl {
url = "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz";
hash = "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==";
"picomatch@4.0.4" = fetchurl {
url = "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz";
hash = "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==";
};
"playwright-core@1.58.2" = fetchurl {
url = "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz";
@@ -3421,6 +3421,10 @@
url = "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz";
hash = "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==";
};
"xml-naming@0.1.0" = fetchurl {
url = "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz";
hash = "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==";
};
"xml@1.0.1" = fetchurl {
url = "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz";
hash = "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==";
@@ -3489,4 +3493,4 @@
url = "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz";
hash = "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==";
};
}
}
+75 -3
View File
@@ -4,15 +4,16 @@ This guide explains how to configure Single Sign-On (SSO) and OpenID Connect (OI
## Overview
Gitea Mirror supports three authentication methods:
Gitea Mirror supports four authentication methods:
1. **Email & Password** - Traditional authentication (always enabled)
2. **SSO (Single Sign-On)** - Allow users to authenticate using external OIDC providers
3. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
3. **Header / Forward Authentication** - Trust pre-authenticated requests from a reverse proxy (Authentik, Authelia, oauth2-proxy, etc.)
4. **OIDC Provider** - Allow other applications to authenticate users through Gitea Mirror
## Configuration
All SSO and OIDC settings are managed through the web UI in the Configuration page under the "Authentication" tab.
SSO and OIDC Provider settings are managed through the web UI in the Configuration page under the "Authentication" tab. Header / Forward Authentication is configured via environment variables only — see [Setting up Header / Forward Authentication](#setting-up-header--forward-authentication) below.
## Setting up SSO (Single Sign-On)
@@ -101,6 +102,77 @@ Notes:
- Authentik must report the users email as verified (default behavior) so Gitea Mirror can auto-link accounts.
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
## Setting up Header / Forward Authentication
Header authentication trusts a reverse proxy (Authentik, Authelia, oauth2-proxy, Traefik forward-auth, etc.) to authenticate users upstream and pass identity in HTTP headers. When enabled, Gitea Mirror reads the configured headers on each request and resolves the user automatically — no login form, no callback.
> **Important — operator-controlled by design.** Header auth is configured via environment variables only, not the UI. This is intentional: trusting a header means trusting whatever upstream sets it, and that decision belongs to the operator who controls the reverse proxy, not to a logged-in user inside the app. Make sure your reverse proxy strips these headers from inbound client requests, otherwise anyone can spoof them.
### Environment variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HEADER_AUTH_ENABLED` | Master switch — must be `true` to enable | `false` |
| `HEADER_AUTH_USER_HEADER` | Header containing the username | `X-Authentik-Username` |
| `HEADER_AUTH_EMAIL_HEADER` | Header containing the email address | `X-Authentik-Email` |
| `HEADER_AUTH_NAME_HEADER` | Header containing the display name | `X-Authentik-Name` |
| `HEADER_AUTH_AUTO_PROVISION` | If `true`, create a new user when an unknown username arrives. If `false`, unknown users are rejected. | `false` |
| `HEADER_AUTH_ALLOWED_DOMAINS` | Comma-separated email domain allowlist. Empty = allow any. | _(empty)_ |
See also: [`docs/ENVIRONMENT_VARIABLES.md`](./ENVIRONMENT_VARIABLES.md#header-authentication-reverse-proxy-sso).
### Example: Docker Compose with Authentik
```yaml
services:
gitea-mirror:
image: ghcr.io/raylabshq/gitea-mirror:latest
environment:
HEADER_AUTH_ENABLED: "true"
HEADER_AUTH_USER_HEADER: "X-Authentik-Username"
HEADER_AUTH_EMAIL_HEADER: "X-Authentik-Email"
HEADER_AUTH_NAME_HEADER: "X-Authentik-Name"
HEADER_AUTH_AUTO_PROVISION: "true"
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,example.org"
```
The defaults are Authentik-shaped, so for an Authentik proxy provider you generally only need to set `HEADER_AUTH_ENABLED=true` (and `HEADER_AUTH_AUTO_PROVISION=true` if you want first-login self-registration).
### Example: Authelia
Authelia uses different header names — override them:
```yaml
environment:
HEADER_AUTH_ENABLED: "true"
HEADER_AUTH_USER_HEADER: "Remote-User"
HEADER_AUTH_EMAIL_HEADER: "Remote-Email"
HEADER_AUTH_NAME_HEADER: "Remote-Name"
HEADER_AUTH_AUTO_PROVISION: "true"
```
Then configure Authelia's `authz` rules to protect the gitea-mirror route and inject the `Remote-*` headers.
### Behaviour and lookup order
- The middleware checks for a cookie session **first**. Header auth only fires when there is no existing session, so users who logged in via password or SSO are not affected.
- Lookup is by `HEADER_AUTH_USER_HEADER` value matched against `users.username`; if no match and `HEADER_AUTH_EMAIL_HEADER` is set, a second lookup is tried against `users.email`.
- If neither matches:
- With `HEADER_AUTH_AUTO_PROVISION=true`, a new user row is created (email defaults to `<username>@header-auth.local` if no email header is present).
- With `HEADER_AUTH_AUTO_PROVISION=false`, the request is rejected with a warning logged.
- If `HEADER_AUTH_ALLOWED_DOMAINS` is non-empty and the email header arrives, the email's domain must be in the list or the request is rejected.
### Verifying it's enabled
When header auth is active, the Authentication settings page renders a green "Header Authentication / Via reverse proxy" badge under the auth-methods status block. You can also probe `/api/auth/header-status`, which returns `{ "enabled": true, ... }` once the env vars are set.
### Security checklist
1. **Strip the headers at your edge.** Your reverse proxy must remove inbound `HEADER_AUTH_USER_HEADER` / email / name headers from client requests before re-injecting its own. Otherwise any unauthenticated client can set `X-Authentik-Username: admin` and walk in.
2. **Bind the app to the proxy only.** Don't expose Gitea Mirror's port directly to the network when header auth is on; only the trusted proxy should reach it.
3. **Use HTTPS between proxy and clients.** Headers travel in plaintext on the link they cross.
4. **Be conservative with `HEADER_AUTH_AUTO_PROVISION`.** With it off, you provision users in the UI/DB once and the proxy fills in sessions thereafter — safer for shared deployments.
## Setting up OIDC Provider
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.16.0",
"version": "3.16.2",
"engines": {
"bun": ">=1.2.9"
},
+57 -3
View File
@@ -421,9 +421,18 @@ export async function syncGiteaRepoEnhanced({
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
}
// Parse repository metadata state up-front. The force-push
// detection below needs the acknowledgedDeletions list to suppress
// already-handled deletions, and a successful snapshot needs to
// record new entries back into the same state object. The
// metadata-mirroring block downstream reuses this same variable.
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
// ---- Smart backup strategy with force-push detection ----
const backupStrategy = resolveBackupStrategy(config);
let forcePushDetected = false;
let forcePushAffected: ReadonlyArray<{ name: string; reason: string; giteaSha: string }> = [];
if (backupStrategy !== "disabled") {
// Run force-push detection if the strategy requires it
@@ -441,9 +450,11 @@ export async function syncGiteaRepoEnhanced({
octokit: fpOctokit,
githubOwner: repository.owner,
githubRepo: repository.name,
acknowledgedDeletions: metadataState.acknowledgedDeletions,
});
forcePushDetected = detectionResult.detected;
forcePushAffected = detectionResult.affectedBranches;
if (detectionResult.skipped) {
console.log(
@@ -457,6 +468,39 @@ export async function syncGiteaRepoEnhanced({
`[Sync] Force-push detected on ${repository.name}: ${branchNames}`,
);
}
// Record each detected "deleted" branch into the
// acknowledged list. We do this based on detection alone,
// BEFORE the backup attempt below, so that:
// - concurrent sync invocations both add the entry
// (the second one whose `createPreSyncBundleBackup`
// short-circuits would otherwise skip the push and
// race its empty in-memory state onto the metadata
// row, undoing the first invocation's write);
// - if the backup later fails the deletion is still
// acknowledged (the branch is genuinely gone from
// GitHub; not re-detecting it next sync is correct
// regardless of whether THIS invocation's backup
// succeeded — prior backups still exist on disk and
// the user can re-trigger one manually if needed).
const newAcknowledged = forcePushAffected
.filter((b) => b.reason === "deleted")
.map((b) => ({ branch: b.name, giteaSha: b.giteaSha }));
if (newAcknowledged.length > 0) {
const existingKeys = new Set(
metadataState.acknowledgedDeletions.map(
(e) => `${e.branch}@${e.giteaSha}`,
),
);
for (const entry of newAcknowledged) {
const key = `${entry.branch}@${entry.giteaSha}`;
if (!existingKeys.has(key)) {
metadataState.acknowledgedDeletions.push(entry);
existingKeys.add(key);
metadataUpdated = true;
}
}
}
} else {
console.log(
`[Sync] Skipping force-push detection for ${repository.name}: no GitHub token`,
@@ -519,8 +563,17 @@ export async function syncGiteaRepoEnhanced({
repositoryName: repository.name,
message: `Snapshot created for ${repository.name}`,
details: `Pre-sync snapshot created at ${backupResult.bundlePath}.`,
status: "syncing",
// The snapshot is already complete (createPreSyncBundleBackup
// returned above). Using "syncing" here left the row in a
// non-terminal state and there was no later code path to
// advance it, so every force-push-triggered backup leaked an
// orphan row that accumulated on the jobs page forever.
status: "synced",
});
// Note: acknowledging the deletion happens earlier (right
// after detection) so concurrent sync invocations both
// record the entry. Don't move it back here — see the
// detection block above for the rationale.
} catch (backupError) {
const errorMessage =
backupError instanceof Error ? backupError.message : String(backupError);
@@ -584,8 +637,9 @@ export async function syncGiteaRepoEnhanced({
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
});
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
// metadataState + metadataUpdated are hoisted above the backup
// strategy block so force-push detection can read/write the
// acknowledged-deletions list. Don't shadow them here.
const skipMetadataForStarred =
repository.isStarred && config.githubConfig?.starredCodeOnly;
let metadataOctokit: Octokit | null = null;
+200
View File
@@ -0,0 +1,200 @@
/**
* Regression test for duplicate milestone & label creation on every sync.
*
* Observed in production (May 2026): a Gitea instance accumulated
* 11,847 duplicate closed milestones across 4 mirrored repos after only
* a handful of scheduled syncs. Two compounding bugs in
* `mirrorGitRepoMilestonesToGitea`:
*
* (1) The existing-milestones GET to Gitea did NOT pass `state=all`.
* Gitea's /milestones endpoint defaults to `state=open`, so the
* `existingMilestones` Set never contained any closed milestone
* title. Every closed GitHub milestone was misclassified as
* missing and re-POSTed on every sync.
*
* (2) The existing-milestones GET was a single unpaginated call.
* Gitea caps response size at `[api].MAX_RESPONSE_ITEMS`
* (default 50), so any repo with more milestones than that
* silently truncates even when (1) is fixed.
*
* `mirrorGitRepoLabelsToGitea` has the same pagination bug (2). It
* doesn't have bug (1) because /labels has no state filter, and it
* hadn't yet shown duplicates in production only because no mirrored
* repo had crossed 50 distinct labels but it would the moment one
* did.
*
* These tests assert on the *structure* of the source rather than
* invoking the functions, because behavioral tests for the metadata
* pipeline require heavy module mocks that pollute other test files
* (bun's mock.module is process-wide). Same convention as
* `gitea-issue-dedup-on-retry.test.ts` and
* `gitea-mirror-failure-recovery.test.ts`.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SOURCE = readFileSync(join(import.meta.dir, "gitea.ts"), "utf8");
/**
* Locate the body of a function declaration by name. Walks from the
* declaration, balances parens to skip the parameter list (which can
* contain destructured object literals with their own braces), then
* finds the body's opening brace and its matching close.
*/
function extractFunctionBody(source: string, declarationStart: RegExp): string {
const match = source.match(declarationStart);
if (!match) {
throw new Error(`Could not locate declaration ${declarationStart}`);
}
let i = match.index! + match[0].length;
while (i < source.length && source[i] !== "(") i++;
if (source[i] !== "(") {
throw new Error(`No '(' after ${declarationStart}`);
}
let parenDepth = 0;
for (; i < source.length; i++) {
if (source[i] === "(") parenDepth++;
else if (source[i] === ")") {
parenDepth--;
if (parenDepth === 0) {
i++;
break;
}
}
}
while (i < source.length && source[i] !== "{") i++;
if (source[i] !== "{") {
throw new Error(`No body '{' for ${declarationStart}`);
}
let braceDepth = 0;
const startIdx = i;
for (; i < source.length; i++) {
if (source[i] === "{") braceDepth++;
else if (source[i] === "}") {
braceDepth--;
if (braceDepth === 0) {
return source.slice(startIdx, i + 1);
}
}
}
throw new Error(`Unterminated body for ${declarationStart}`);
}
describe("milestone dedup on sync", () => {
const body = extractFunctionBody(
SOURCE,
/export async function mirrorGitRepoMilestonesToGitea\b/
);
test("body contains the per-milestone create branch we expect to guard", () => {
// Sanity check: anchor the rest of this suite to the right path.
expect(
body.includes("existingMilestones"),
"expected the existingMilestones set/map used for dedup"
).toBe(true);
expect(
body.match(
/await httpPost\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/milestones`/
),
"expected the create-milestone httpPost call"
).toBeTruthy();
});
test("existing-milestones GET must include state=all", () => {
// Without state=all, Gitea returns only open milestones, so every
// closed GitHub milestone is misclassified as missing and re-POSTed
// on every sync. This was the root cause of the production blowup.
const getMatch = body.match(
/httpGet\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/milestones\?[^`]*`/
);
expect(
getMatch,
"expected a milestones httpGet call with a query string"
).toBeTruthy();
expect(
/state=all/.test(getMatch![0]),
"the existing-milestones GET must pass state=all (Gitea defaults to state=open)"
).toBe(true);
});
test("existing-milestones GET must paginate with both Link and X-Total-Count fallback", () => {
// Even with state=all, a single unpaginated call only ever sees
// the first 50 milestones (Gitea's MAX_RESPONSE_ITEMS cap).
//
// Gitea's /milestones endpoint does NOT emit a Link header — only
// `X-Total-Count`. A strict Link-only check terminates after page 1
// and re-POSTs every milestone past index 50 on every sync (the
// 9-milestone leak observed on Subnet-Calculator after the
// Link-only version of this fix shipped).
expect(
/milestonesPage\s*\+=\s*1/.test(body),
"expected a page-increment loop for existing milestones"
).toBe(true);
expect(
/\.headers\.get\(\s*["']link["']\s*\)/.test(body) &&
/rel="next"/.test(body),
"the milestones pagination loop must check the Link header (rel=\"next\")"
).toBe(true);
expect(
/\.headers\.get\(\s*["']x-total-count["']\s*\)/.test(body),
"the milestones pagination loop must fall back to X-Total-Count when Link header is absent (Gitea /milestones only emits X-Total-Count)"
).toBe(true);
});
test("newly-created milestone must be cached into existingMilestones", () => {
// Defensive: if `milestones` ever contains a same-named entry
// twice (unlikely but cheap to guard), we shouldn't POST it twice.
expect(
/existingMilestones\.add\(\s*milestone\.title\s*\)/.test(body),
"after a successful create, the new milestone title must be added to existingMilestones"
).toBe(true);
});
});
describe("label dedup on sync", () => {
const body = extractFunctionBody(
SOURCE,
/export async function mirrorGitRepoLabelsToGitea\b/
);
test("body contains the per-label create branch we expect to guard", () => {
expect(
body.includes("existingLabels"),
"expected the existingLabels set used for dedup"
).toBe(true);
expect(
body.match(
/await httpPost\(\s*`\$\{config\.giteaConfig\.url\}\/api\/v1\/repos\/\$\{giteaOwner\}\/\$\{repoName\}\/labels`/
),
"expected the create-label httpPost call"
).toBeTruthy();
});
test("existing-labels GET must paginate with both Link and X-Total-Count fallback", () => {
// Same Gitea MAX_RESPONSE_ITEMS=50 cap as milestones / issues.
// Gitea's /labels endpoint, like /milestones, does NOT emit a Link
// header — only `X-Total-Count`. Strict Link-only check would
// silently truncate after page 1.
expect(
/labelsPage\s*\+=\s*1/.test(body),
"expected a page-increment loop for existing labels"
).toBe(true);
expect(
/\.headers\.get\(\s*["']link["']\s*\)/.test(body) &&
/rel="next"/.test(body),
"the labels pagination loop must check the Link header (rel=\"next\")"
).toBe(true);
expect(
/\.headers\.get\(\s*["']x-total-count["']\s*\)/.test(body),
"the labels pagination loop must fall back to X-Total-Count when Link header is absent (Gitea /labels only emits X-Total-Count)"
).toBe(true);
});
test("newly-created label must be cached into existingLabels", () => {
expect(
/existingLabels\.add\(\s*label\.name\s*\)/.test(body),
"after a successful create, the new label name must be added to existingLabels"
).toBe(true);
});
});
+95 -20
View File
@@ -3408,17 +3408,49 @@ export async function mirrorGitRepoLabelsToGitea({
return;
}
// Get existing labels from Gitea
const giteaLabelsRes = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
// Get existing labels from Gitea. Paginate because Gitea caps
// response size at `[api].MAX_RESPONSE_ITEMS` (default 50), so a
// single unpaginated GET only sees the first 50 labels. Once a repo
// crosses that threshold every label past it would be re-POSTed as a
// duplicate on every sync.
//
// Pagination signal: prefer Link header (RFC 5988) when present, but
// Gitea's /labels and /milestones endpoints do NOT emit Link headers
// — they only emit `X-Total-Count`. Without the fallback, a strict
// Link-only check terminated after page 1 and re-POSTed every label
// past index 50 on every sync. (Repro found during the milestone
// dedup fix: 9 unique milestones leaked past page 1 on a 74-row
// /milestones response.)
const existingLabels = new Set<string>();
const labelsPerPage = 50;
let labelsPage = 1;
let labelsFetched = 0;
while (true) {
const giteaLabelsRes = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels?page=${labelsPage}&limit=${labelsPerPage}`,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
const pageLabels = Array.isArray(giteaLabelsRes.data) ? giteaLabelsRes.data : [];
if (!pageLabels.length) break;
for (const lbl of pageLabels) existingLabels.add(lbl.name);
labelsFetched += pageLabels.length;
const existingLabels = new Set(
giteaLabelsRes.data.map((label: any) => label.name)
);
const linkHeader = giteaLabelsRes.headers.get("link") || "";
if (/\brel="next"/.test(linkHeader)) {
labelsPage += 1;
continue;
}
// No Link header (or no rel=next). Fall back to X-Total-Count.
const totalStr = giteaLabelsRes.headers.get("x-total-count");
const total = totalStr ? Number.parseInt(totalStr, 10) : NaN;
if (Number.isFinite(total) && labelsFetched < total) {
labelsPage += 1;
continue;
}
break;
}
let mirroredCount = 0;
for (const label of labels) {
@@ -3435,6 +3467,9 @@ export async function mirrorGitRepoLabelsToGitea({
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
// Track locally so a duplicate in `labels` (shouldn't happen,
// but defensive) doesn't trigger a second POST in the same run.
existingLabels.add(label.name);
mirroredCount++;
} catch (error) {
console.error(
@@ -3508,17 +3543,54 @@ export async function mirrorGitRepoMilestonesToGitea({
return;
}
// Get existing milestones from Gitea
const giteaMilestonesRes = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones`,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
// Get existing milestones from Gitea. Two correctness requirements:
// 1. `state=all` — Gitea's /milestones endpoint defaults to OPEN
// only, so without this every CLOSED GitHub milestone is
// misclassified as missing and re-POSTed on every sync. This
// was the root cause of the 11k+ duplicate-closed-milestone
// blowup observed in production.
// 2. Pagination via Link header (RFC 5988) — Gitea caps response
// size at `[api].MAX_RESPONSE_ITEMS` (default 50), so any repo
// with more than ~50 milestones in a given state silently
// truncates without it. Same Gitea-side cap that bit the
// issues / PRs pre-fetch in commit b76073b.
// Pagination signal: prefer Link header (RFC 5988) when present, but
// Gitea's /milestones endpoint does NOT emit a Link header — it only
// emits `X-Total-Count`. A strict Link-only check terminates after
// page 1 and re-POSTs every milestone past index 50 on every sync.
// (Repro: post-fix deploy on Subnet-Calculator leaked 9 unique
// milestones past page 1 of a 74-row /milestones response.)
const existingMilestones = new Set<string>();
const milestonesPerPage = 50;
let milestonesPage = 1;
let milestonesFetched = 0;
while (true) {
const giteaMilestonesRes = await httpGet(
`${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones?state=all&page=${milestonesPage}&limit=${milestonesPerPage}`,
{
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
const pageMilestones = Array.isArray(giteaMilestonesRes.data)
? giteaMilestonesRes.data
: [];
if (!pageMilestones.length) break;
for (const ms of pageMilestones) existingMilestones.add(ms.title);
milestonesFetched += pageMilestones.length;
const existingMilestones = new Set(
giteaMilestonesRes.data.map((milestone: any) => milestone.title)
);
const linkHeader = giteaMilestonesRes.headers.get("link") || "";
if (/\brel="next"/.test(linkHeader)) {
milestonesPage += 1;
continue;
}
const totalStr = giteaMilestonesRes.headers.get("x-total-count");
const total = totalStr ? Number.parseInt(totalStr, 10) : NaN;
if (Number.isFinite(total) && milestonesFetched < total) {
milestonesPage += 1;
continue;
}
break;
}
let mirroredCount = 0;
for (const milestone of milestones) {
@@ -3536,6 +3608,9 @@ export async function mirrorGitRepoMilestonesToGitea({
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
}
);
// Track locally so a duplicate within `milestones` (shouldn't
// happen, but defensive) doesn't trigger a second POST.
existingMilestones.add(milestone.title);
mirroredCount++;
} catch (error) {
console.error(
+28
View File
@@ -6,9 +6,22 @@ interface MetadataComponentsState {
milestones: boolean;
}
/**
* One-shot record of a deleted-branch backup we already took, so the
* force-push detector knows to skip the same (branch, giteaSha) pair
* next sync. Without this, deleted-on-GitHub branches that linger in
* the Gitea mirror trip the detector every cycle and create a new
* "Snapshot created" job row forever.
*/
export interface AcknowledgedDeletion {
branch: string;
giteaSha: string;
}
export interface RepositoryMetadataState {
components: MetadataComponentsState;
lastSyncedAt?: string;
acknowledgedDeletions: AcknowledgedDeletion[];
}
const defaultComponents: MetadataComponentsState = {
@@ -22,6 +35,7 @@ const defaultComponents: MetadataComponentsState = {
export function createDefaultMetadataState(): RepositoryMetadataState {
return {
components: { ...defaultComponents },
acknowledgedDeletions: [],
};
}
@@ -65,6 +79,20 @@ export function parseRepositoryMetadataState(
base.lastSyncedAt = parsed.lastMetadataSync;
}
if (Array.isArray(parsed.acknowledgedDeletions)) {
base.acknowledgedDeletions = parsed.acknowledgedDeletions.flatMap(
(entry: unknown): AcknowledgedDeletion[] => {
if (!entry || typeof entry !== "object") return [];
const branch = (entry as { branch?: unknown }).branch;
const giteaSha = (entry as { giteaSha?: unknown }).giteaSha;
if (typeof branch !== "string" || typeof giteaSha !== "string") {
return [];
}
return [{ branch, giteaSha }];
}
);
}
return base;
}
+185
View File
@@ -316,4 +316,189 @@ describe("detectForcePush", () => {
expect(result.skipped).toBe(true);
expect(result.skipReason).toContain("Failed to fetch GitHub branches");
});
// --- acknowledgedDeletions: suppress already-handled deleted branches ---
//
// Production reproduction (Simple-WP-Helpdesk, May 2026): a branch
// deleted on GitHub remained in the Gitea mirror because gitea-mirror
// is one-way. Every 4h sync re-detected it as "deleted" and inserted
// a fresh "Snapshot created" job row — 7 zombies accumulated in 24h.
// Fix: caller threads in the list of (branch, giteaSha) pairs already
// backed up; detector suppresses matching entries.
it("suppresses a deleted branch when acknowledged at the same giteaSha", async () => {
const deps = makeDeps({
giteaBranches: [
{ name: "main", sha: "aaa" },
{ name: "fix/abandoned", sha: "bbb" },
],
githubBranches: [{ name: "main", sha: "aaa" }],
});
const result = await detectForcePush({
...baseArgs,
octokit: dummyOctokit,
acknowledgedDeletions: [{ branch: "fix/abandoned", giteaSha: "bbb" }],
_deps: deps,
});
expect(result.detected).toBe(false);
expect(result.affectedBranches).toHaveLength(0);
});
it("re-flags a previously-acknowledged branch if its giteaSha changed", async () => {
// Edge case: a deleted branch was restored (Gitea picked up the
// new history), then re-deleted. Same name but different giteaSha
// means the acknowledged entry doesn't match — back up the new
// state.
const deps = makeDeps({
giteaBranches: [
{ name: "main", sha: "aaa" },
{ name: "fix/abandoned", sha: "ccc" },
],
githubBranches: [{ name: "main", sha: "aaa" }],
});
const result = await detectForcePush({
...baseArgs,
octokit: dummyOctokit,
acknowledgedDeletions: [{ branch: "fix/abandoned", giteaSha: "bbb" }], // stale SHA
_deps: deps,
});
expect(result.detected).toBe(true);
expect(result.affectedBranches).toHaveLength(1);
expect(result.affectedBranches[0]).toMatchObject({
name: "fix/abandoned",
reason: "deleted",
giteaSha: "ccc",
});
});
it("suppresses only the acknowledged deletion when multiple deletions exist", async () => {
const deps = makeDeps({
giteaBranches: [
{ name: "main", sha: "aaa" },
{ name: "fix/old", sha: "bbb" },
{ name: "fix/new", sha: "ccc" },
],
githubBranches: [{ name: "main", sha: "aaa" }],
});
const result = await detectForcePush({
...baseArgs,
octokit: dummyOctokit,
acknowledgedDeletions: [{ branch: "fix/old", giteaSha: "bbb" }],
_deps: deps,
});
expect(result.detected).toBe(true);
expect(result.affectedBranches).toHaveLength(1);
expect(result.affectedBranches[0]?.name).toBe("fix/new");
});
it("treats undefined acknowledgedDeletions as empty (back-compat with callers that don't pass it)", async () => {
const deps = makeDeps({
giteaBranches: [
{ name: "main", sha: "aaa" },
{ name: "fix/abandoned", sha: "bbb" },
],
githubBranches: [{ name: "main", sha: "aaa" }],
});
const result = await detectForcePush({
...baseArgs,
octokit: dummyOctokit,
// acknowledgedDeletions omitted
_deps: deps,
});
expect(result.detected).toBe(true);
expect(result.affectedBranches[0]?.reason).toBe("deleted");
});
it("does not suppress diverged branches via the acknowledgedDeletions list", async () => {
// The suppression list is specifically for `reason: "deleted"`.
// A divergence at the same name + matching old giteaSha (an
// impossible-in-practice combination, but be explicit about the
// boundary) must still report.
const deps = makeDeps({
giteaBranches: [{ name: "main", sha: "aaa" }],
githubBranches: [{ name: "main", sha: "rewritten" }],
ancestryResult: false,
});
const result = await detectForcePush({
...baseArgs,
octokit: dummyOctokit,
acknowledgedDeletions: [{ branch: "main", giteaSha: "aaa" }],
_deps: deps,
});
expect(result.detected).toBe(true);
expect(result.affectedBranches[0]?.reason).toBe("diverged");
});
});
// --- metadata-state round-trip for the new acknowledgedDeletions field ---
describe("metadata-state acknowledgedDeletions persistence", () => {
it("parse → mutate → serialize → parse round-trips entries cleanly", async () => {
const {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
createDefaultMetadataState,
} = await import("../metadata-state");
const state = createDefaultMetadataState();
state.acknowledgedDeletions.push(
{ branch: "fix/abandoned", giteaSha: "bbb" },
{ branch: "fix/other", giteaSha: "ccc" },
);
const reparsed = parseRepositoryMetadataState(
serializeRepositoryMetadataState(state),
);
expect(reparsed.acknowledgedDeletions).toEqual([
{ branch: "fix/abandoned", giteaSha: "bbb" },
{ branch: "fix/other", giteaSha: "ccc" },
]);
});
it("defaults acknowledgedDeletions to [] for legacy metadata blobs", async () => {
const { parseRepositoryMetadataState } = await import("../metadata-state");
// Metadata that predates this field — no acknowledgedDeletions key
const legacy = JSON.stringify({
components: {
releases: true,
issues: false,
pullRequests: false,
labels: false,
milestones: false,
},
lastSyncedAt: "2026-05-01T00:00:00Z",
});
expect(parseRepositoryMetadataState(legacy).acknowledgedDeletions).toEqual([]);
});
it("drops malformed acknowledged entries without throwing", async () => {
const { parseRepositoryMetadataState } = await import("../metadata-state");
const malformed = JSON.stringify({
components: {},
acknowledgedDeletions: [
{ branch: "good", giteaSha: "abc" },
{ branch: 42, giteaSha: "abc" }, // bad type
null,
{ branch: "missing-sha" },
],
});
expect(parseRepositoryMetadataState(malformed).acknowledgedDeletions).toEqual([
{ branch: "good", giteaSha: "abc" },
]);
});
});
+27 -1
View File
@@ -11,6 +11,7 @@
import type { Octokit } from "@octokit/rest";
import { httpGet, HttpError } from "@/lib/http-client";
import type { AcknowledgedDeletion } from "@/lib/metadata-state";
// ---- Types ----
@@ -172,6 +173,7 @@ export async function detectForcePush({
octokit,
githubOwner,
githubRepo,
acknowledgedDeletions,
_deps,
}: {
giteaUrl: string;
@@ -181,6 +183,18 @@ export async function detectForcePush({
octokit: Octokit;
githubOwner: string;
githubRepo: string;
/**
* Deleted-branch backups we already took. A Gitea branch missing
* from GitHub is suppressed from `affectedBranches` when its current
* giteaSha matches an entry here. Without this, deleted branches
* trip detection every sync because gitea-mirror is one-way:
* deletions never propagate to the Gitea mirror, so the "branch in
* Gitea, gone from GitHub" condition holds forever and we'd take a
* fresh snapshot on every cycle.
*
* Stored on the repository row via RepositoryMetadataState.
*/
acknowledgedDeletions?: readonly AcknowledgedDeletion[];
/** @internal — test-only dependency injection */
_deps?: {
fetchGiteaBranches: typeof fetchGiteaBranches;
@@ -189,6 +203,9 @@ export async function detectForcePush({
};
}): Promise<ForcePushDetectionResult> {
const deps = _deps ?? { fetchGiteaBranches, fetchGitHubBranches, checkAncestry };
const acknowledged = new Set(
(acknowledgedDeletions ?? []).map((entry) => `${entry.branch}@${entry.giteaSha}`),
);
// 1. Fetch Gitea branches
let giteaBranches: BranchInfo[];
@@ -237,7 +254,16 @@ export async function detectForcePush({
const githubSha = githubBranchMap.get(giteaBranch.name);
if (githubSha === undefined) {
// Branch was deleted on GitHub
// Branch was deleted on GitHub. Suppress if we already took a
// snapshot at this exact giteaSha — the deletion is permanent
// on the GitHub side but the branch lingers in the Gitea
// mirror, so without this check the detector trips every sync.
// If the giteaSha later changes (branch restored, then deleted
// again with new history), the entry won't match and we'll
// back up the new state.
if (acknowledged.has(`${giteaBranch.name}@${giteaBranch.sha}`)) {
continue;
}
affected.push({
name: giteaBranch.name,
reason: "deleted",