Compare what Gitea or Forgejo holds under every owner this account
mirrors into with the repositories table. Mirrors of the configured
source that have no row are reported as untracked and can be adopted;
rows marked mirrored whose repository is gone are confirmed with a
direct check and can be reset to imported so the next run recreates
them. Native repositories and mirrors of other hosts are listed and
never touched, and nothing is deleted or archived. Exposed as
POST /api/cleanup/reconcile and as a dialog on the Automation tab.
Closes#284
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
* feat(auth): API keys for programmatic access
Adds the @better-auth/api-key plugin so scripts and CI pipelines can call
the existing endpoints with an x-api-key header instead of a session
cookie. Keys are owned by a user, hashed at rest, prefixed gm_, never
expire unless an expiry is chosen, and are not rate limited. A small
guard plugin refuses key management calls that arrive with a key, so a
leaked key cannot mint or revoke keys.
New API Keys section on the Authentication tab with create, show once,
copy and revoke. Migration 0017 adds the api_keys table with the
validator fixture. docs/API.md documents the header and the calls
automation needs. An e2e spec covers create, use, refuse and revoke over
HTTP. bun.nix regenerated for the new package.
Closes#314
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
* fix(e2e): send Origin on cookie-authenticated key management calls
Better Auth rejects a cookie-authenticated POST without an Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one, the Playwright request context does not, so the spec sets it on the create and delete calls. Also asserts the guard's 403 code and documents the Origin requirement for scripts that manage keys with a session.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
Adds a release asset limit next to the release limit. The release limit
still decides how many of the newest releases exist in Gitea at all. The
new asset limit decides how many of those also get their assets uploaded;
older releases inside the release limit are created with their notes and
tag only. Unset means assets for every mirrored release, which is what
happened before, so existing setups do not change. Zero means release
notes only. The limit inherits global to organization to repository
through the existing limit-key machinery, and lowering it never deletes
assets that were already uploaded; it only gates what a sync uploads.
Settings gets a second box next to the release limit, the overrides dialog
gets a matching row for organizations and repositories, and the env loader
reads RELEASE_ASSET_LIMIT. The README explains both limits and gains a
troubleshooting entry for the read-only push error people hit on pull
mirrors.
Closes#311Closes#59
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
Adds a Source dropdown to the configuration card (GitHub, GitLab beta,
Gitea/Forgejo) backed by a source provider interface with three adapters.
Discovery, mirroring, recovery and cleanup go through the provider;
repositories record their source (migration 0015); clone credentials are
built per host and a repository from another host is refused at the
mirror sites. Issues, pull requests, releases and star lists stay GitHub
only. The destination card gets a Gitea/Forgejo dropdown, and both hosts
lock once repositories exist, with an explicit confirmation to change.
Closes#375. Helps #371.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
A job that had just started, with in_progress=1 and no checkpoint yet,
matched findInterruptedJobs immediately. Since #297 the middleware runs
that check on every request, so a live job was "resumed" by recovery
while the original process was still working on the same repositories.
The request-level 15 second timeout then released the in-flight latch
without cancelling recovery, and later requests logged misleading
"already in progress" and "completed with some issues" lines.
- Move the liveness rule into interrupted-job-detection.ts, as both a
predicate and the SQL condition used by findInterruptedJobs. A job
with no checkpoint is only interrupted once it is older than the 10
minute checkpoint window (or has no recorded start at all).
- Stamp an initial checkpoint on in-progress jobs at creation.
- Refresh the checkpoint every 2 minutes from processWithResilience so a
single long item cannot make a live job look interrupted. The refresh
only touches rows still in progress.
- Keep the middleware recovery latch held until the recovery promise
settles, not until the request stops waiting, and clear the timeout
timer so it no longer leaves a dangling rejection.
Tests cover the predicate, the SQL against an in-memory SQLite database,
and the wiring into helpers, concurrency, and middleware.
Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
Three fixes for the two problems reported in #366:
1. Auto-trust registered SSO identity provider origins. better-auth
1.6.23 (shipped in v3.21.0) added SSRF hardening to the SSO plugin:
sign-in rejects IdP endpoints whose hostnames resolve to private
addresses unless the origin is in trustedOrigins. Homelab split-DNS
setups (IdP domain resolving to a LAN IP from inside the container)
broke on every sign-in with a 400. Registering a provider is an
explicit operator action, so its issuer and endpoint origins are now
added to trusted origins automatically.
2. Surface SSO sign-in errors in the login form. The auth client
resolves with { data, error } instead of throwing, so server-side
rejections were silently swallowed - the button flipped back from
"Redirecting..." with no feedback and nothing in the logs.
3. Honor deleteFromGitea (CLEANUP_DELETE_FROM_GITEA). It was documented
as "Delete repositories from Gitea" defaulting to false, but cleanup
never read it and always archived/deleted orphans on the Gitea side.
It now gates the Gitea-side operation: when disabled (default),
orphans are only marked archived or removed in gitea-mirror's own
database and the Gitea/Forgejo copies stay untouched.
Verified end to end against a live server: sign-in with a private-IP
IdP returns 400 discovery_private_host on v3.27.1 and a 200 with the
authorization URL on this branch; the login form now shows the server
error as a toast; delete cleanup with the flag off removes only the DB
row while the flag on contacts Gitea.
* docs: move canonical documentation to the website
The website now hosts the full documentation at /docs with a proper docs
layout: sidebar navigation, on-page table of contents, mobile nav, theme
support, canonical and OG meta, and overflow-safe code blocks and tables.
Ten pages, all rewritten from the current code rather than copied from
the old in-app docs: quickstart, deployment (Docker, Helm, Nix, LXC,
bare metal), configuration, environment variable reference,
notifications (all four providers including webhook payload signing),
authentication (including header auth), force-push protection,
architecture, advanced, and custom CA certificates.
This fixes every inaccuracy found in the docs audit: the wrong
raylabs/gitea-mirror image name, JWT_SECRET presented as the live auth
secret instead of BETTER_AUTH_SECRET, the missing auth env vars, both
wrong DATABASE_URL defaults, the contradicting starred-org default, and
health endpoint fields the API deliberately does not return.
The in-app /docs pages are retired: a stub redirects old bookmarks to
the website, and the sidebar and 404 links point there directly. The
markdown files under docs/ stay as the versioned offline reference;
NOTIFICATIONS.md now covers Gotify and Webhook. README links the docs
site, mentions notifications, and drops stale version markers. The app
viewport meta gains initial-scale=1.
* docs: mark new-repo notification as unimplemented, bump helm appVersion to 3.24.0
* feat(ui): redesign dashboard and configuration screens
New settings design language built from the design/giteamirror.pen file:
cards with icon headers and status footers, header-level enable switches,
toggle switches instead of checkboxes, uppercase section titles, selection
tiles with icon chips and a check on the active option, segmented controls,
and an indigo accent. Implemented via shared primitives in
src/components/config/settings-ui.tsx and applied across:
- Automation: header switches, schedule card, one-line auto-mirror copy
with info tooltip, full-width Repository Cleanup card with Skip/Archive/
Delete tiles and dry run row
- Notifications: segmented provider picker (ntfy/Apprise/Gotify/Webhook),
events card with per-event switches
- Connections: GitHub/Gitea connection cards with token creation guide and
field helpers, Repository Selection and Mirror Content cards covering
every mirror option, Organization Structure card with strategy tiles,
destructive update protection tiles (BETA label removed)
- Authentication: sign-in methods status card, identity providers restyle
- Dashboard: flatter stat cards, icon panel headers, indigo view-all links
All existing state handling, autosave and API behavior is unchanged.
Light mode keeps working via theme tokens. README and website screenshots
regenerated, docs references to renamed cards updated.
* fix(ui): design polish pass from local review
- Recent Activity rows get status icon circles (check, sync, sparkles, alert)
- Connections tab restructured: connection cards share a stretched grid row
so GitHub and Gitea stay equal height; Mirror Content moved to the right
column; forms split into placeable cards via a part prop
- Token guide panel: link moved to header as icon, larger text; redundant
card footers removed (scopes line, test-connection hint)
- Repository Selection gains a footer note; retention explanation moved
below the selector
- Authentication tab matches the design: side-by-side cards, row dividers,
disabled state-reflecting switches with info hints, footers; SSO dialog
restyled (segmented protocol tabs, field labels, indigo primary)
- Import GitHub Data button is the indigo primary; disabled state is muted
- Time format menu redesigned (locale pill, live examples, live clock in
the trigger); theme switcher moved to sidebar as icon segmented control,
system preference now persists correctly; legacy ModeToggle removed
- Automation timezone pill no longer shows stored legacy UTC as a choice
- Config tab bar wraps 2x2 on narrow screens instead of overflowing
- README, website and PR screenshots regenerated
The "Name collision strategy" dropdown (starredDuplicateStrategy) never
persisted: the field was absent from both directions of the UI<->DB config
mapper. On save, mapUiToDbConfig dropped it before the DB write; on load,
mapDbToUiConfig never read it, so the UI reset to the "suffix" (repo-owner)
default. Mirror logic in gitea.ts then read undefined and also defaulted to
suffix — so repos really were created with that pattern regardless of the
user's choice. It has been broken since the field was introduced.
- Map starredDuplicateStrategy in mapUiToDbConfig and mapDbToUiConfig
- Add STARRED_DUPLICATE_STRATEGY env var for parity (reporter could not
work around it via compose because no env var existed) + docs
- Round-trip tests covering save, load, and the missing-field default
Repository discovery requested the `organization_member` affiliation
unconditionally, so repos from every org a user belongs to were imported —
even orgs they never explicitly added. `skipPersonalRepos` only dropped
user-owned repos and left org repos unfiltered, which surprised users who
expected "only mirror org repos" to mean "only the orgs I chose" (reported
on #304).
Wire up the previously-dormant `includeOrganizations` config field as an
opt-in allowlist: when non-empty, only repos owned by the listed
organizations are imported. Empty = all org repos (backward-compatible).
Owned and collaborator repos are never restricted, so it composes cleanly
with `skipPersonalRepos`.
- Filter org repos by the allowlist in getGithubRepositories
- Add includeAllOrgsOverride so the cleanup service bypasses the allowlist
and never false-orphans a previously-mirrored repo from an org the user
later removes from the list
- UI control under Filtering & Behavior; INCLUDE_ORGANIZATIONS env var
- Case-insensitive dedup/trim in the UI<->DB mapper round-trip
- 7 unit tests covering the filter, composition, and the cleanup override
- Add `skipPersonalRepos: z.boolean().default(false)` to githubConfigSchema
- Filter out user-owned repos in getGithubRepositories when flag is true
- Wire ONLY_MIRROR_ORGS env var to skipPersonalRepos in env-config-loader
- Add checkbox UI in GitHubMirrorSettings Filtering & Behavior section
- Round-trip skipPersonalRepos through config-mapper (UI ↔ DB)
- Add skipPersonalRepos to AdvancedOptions TypeScript type
- Mark include/exclude arrays in configSchema as unused/reserved
- Update ENVIRONMENT_VARIABLES.md to document ONLY_MIRROR_ORGS effect
Resolves#306. SSO sign-in via OIDC (Authentik / Keycloak / etc.) now links the
SSO identity to an existing email/password admin instead of bouncing to /login
with `?error=UNKNOWN`. Account-linking is gated on the operator-supplied
**Domain** field — cross-domain claims from a compromised IdP are refused.
Also bundles the deprecated `oidcProvider` → `@better-auth/oauth-provider`
migration. **Operators using the OAuth-provider feature must rotate registered
client secrets after upgrade** (legacy plaintext → hashed storage; see the
0012 migration notes).
Verified end-to-end on the pr-307 image against a real Authentik instance:
SSO login lands on the dashboard, `accounts` table gets both `credential` and
`authentik` rows for the same user. See PR description for full details.
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.
GitHub's listForAuthenticatedUser defaults to returning every repo the
user has access to (owner + collaborator + organization_member), which
imports a lot of noise for users who only want their own repos.
Adds an `includeCollaboratorRepos` toggle, defaulting to true to preserve
existing behavior. When disabled, the affiliation filter scopes the API
call to "owner" only.
The cleanup service overrides the filter to always include collaborator
repos when computing the "what's still on GitHub" list. Without this,
toggling the option off would mark previously-mirrored collab repos as
orphaned and archive/delete them from Gitea.
Wired through the schema, both UI<->DB mappers, the env-config loader
(with new INCLUDE_COLLABORATOR_REPOS env var), and the settings UI.
- README + env reference + .env.example now cover using GH_API_URL to
target GitHub Enterprise Server or GHEC with data residency.
- Env reference + .env.example now cover SERVER_CERT_PATH and
SERVER_KEY_PATH, which @astrojs/node reads at runtime to terminate
TLS directly without a reverse proxy.
Closes#269Closes#272
Update README, ENVIRONMENT_VARIABLES.md, and advanced docs page to
explicitly state that BETTER_AUTH_URL and PUBLIC_BETTER_AUTH_URL must be
origin only (scheme + host). The BASE_URL path prefix is applied
automatically — any path accidentally included is stripped.
* feat: add custom sync start time scheduling
* Updated UI
* docs: add updated issue 240 UI screenshot
* fix: improve schedule UI with client-side next run calc and timezone handling
- Compute next scheduled run client-side via useMemo to avoid permanent
"Calculating..." state when server hasn't set nextRun yet
- Default to browser timezone when enabling syncing (not UTC)
- Show actual saved timezone in badge, use it consistently in all handlers
- Match time input background to select trigger in dark mode
- Add clock icon to time picker with hidden native indicator
* feat: add notification system with Ntfy.sh and Apprise providers (#231)
Add push notification support for mirror job events with two providers:
- Ntfy.sh: direct HTTP POST to ntfy topics with priority/tag support
- Apprise API: aggregator gateway supporting 100+ notification services
Includes database migration (0010), settings UI tab, test endpoint,
auto-save integration, token encryption, and comprehensive tests.
Notifications are fire-and-forget and never block the mirror flow.
* fix: address review findings for notification system
- Fix silent catch in GET handler that returned ciphertext to UI,
causing double-encryption on next save. Now clears token to ""
on decryption failure instead.
- Add Zod schema validation to test notification endpoint, following
project API route pattern guidelines.
- Mark notifyOnNewRepo toggle as "coming soon" with disabled state,
since the backend doesn't yet emit new_repo events. The schema
and type support is in place for when it's implemented.
* fix notification gating and config validation
* trim sync notification details
* fix: prevent excessive disk usage from repo backups (#234)
Legacy configs with backupBeforeSync: true but no explicit backupStrategy
silently resolved to "always", creating full git bundles on every sync
cycle. This caused repo-backups to grow to 17GB+ for users with many
repositories.
Changes:
- Fix resolveBackupStrategy to map backupBeforeSync: true → "on-force-push"
instead of "always", so legacy configs only backup when force-push is detected
- Fix config mapper to always set backupStrategy explicitly ("on-force-push")
preventing the backward-compat fallback from triggering
- Lower default backupRetentionCount from 20 to 5 bundles per repo
- Add time-based retention (backupRetentionDays, default 30 days) alongside
count-based retention, with safety net to always keep at least 1 bundle
- Add "high disk usage" warning on "Always Backup" UI option
- Update docs and tests to reflect new defaults and behavior
* fix: preserve legacy backupBeforeSync:false on UI round-trip and expose retention days
P1: mapDbToUiConfig now checks backupBeforeSync === false before
defaulting backupStrategy, preventing legacy "disabled" configs from
silently becoming "on-force-push" after any auto-save round-trip.
P3: Added "Snapshot retention days" input field to the backup settings
UI, matching the documented setting in FORCE_PUSH_PROTECTION.md.
* feat: add autoMirrorStarred toggle for selective starred repo mirroring (#205)
Add `githubConfig.autoMirrorStarred` (default: false) to control whether
starred repos are included in automatic mirroring operations. Manual
per-repo actions always work regardless of this toggle.
Bug fixes:
- Cleanup service no longer orphans starred repos when includeStarred is
disabled (prevents data loss)
- First-boot auto-start now gates initial mirror behind autoMirror config
(previously mirrored everything unconditionally)
- "Mirror All" button now respects autoMirrorStarred setting
- Bulk mirror and getAvailableActions now include pending-approval status
Changes span schema, config mapping, env loader, scheduler, cleanup
service, UI settings toggle, and repository components.
* fix: log activity when repos are auto-imported during scheduled sync
Auto-discovered repositories (including newly starred ones) were inserted
into the database without creating activity log entries, so they appeared
in the dashboard but not in the activity log.
* ci: set 10-minute timeout on all CI jobs
* 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
* feat: add target organization field to Add Repository dialog
Allow users to specify a destination Gitea organization when adding a
single repository, instead of relying solely on the default mirror
strategy. The field is optional — when left empty, the existing strategy
logic applies as before.
Closes#200
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add screenshot of target organization field in Add Repository dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Use DeterminateSystems/nix-installer-action for Nix installation
- Use DeterminateSystems/magic-nix-cache-action for caching (free, no setup)
- Update documentation to remove Cachix references
- Add nix branch to CI triggers
Updated development documentation to reflect current project structure
and simplified setup process.
Changes:
- DEVELOPMENT_WORKFLOW.md: Updated repository URL, simplified setup steps,
improved project structure documentation, and clarified command descriptions
- README.md: Reorganized as a concise index of available guides, removed
redundant content now covered in main README and in-app help
- SHUTDOWN_PROCESS.md: Removed (content consolidated into GRACEFUL_SHUTDOWN.md)
These updates make the documentation more accurate and easier to navigate
for new contributors.
- Prevent Automation UI from overriding schedule:
- mapDbScheduleToUi now parses intervals robustly (cron/duration/seconds) via parseInterval
- mapUiScheduleToDb merges with existing config and stores interval as seconds (no lossy cron conversion)
- /api/config passes existing scheduleConfig to preserve ENV-sourced values
- schedule-sync endpoint uses parseInterval for nextRun calculation
- Add AUTO_MIRROR_REPOS support and scheduled auto-mirror phase:
- scheduleConfig schema includes autoImport and autoMirror
- env-config-loader reads AUTO_MIRROR_REPOS and carries through to DB
- scheduler auto-mirrors imported/pending/failed repos when autoMirror is enabled before regular sync
- docker-compose and ENV docs updated with AUTO_MIRROR_REPOS
- Tests pass and build succeeds