The "Auto-mirror new starred repositories" checkbox in the GitHub settings
was a filter layered on top of scheduleConfig.autoMirror, which itself is
only settable via the AUTO_MIRROR_REPOS env var (no UI). So users who
checked the box saw their starred repos auto-imported but never mirrored.
Treat autoMirror and autoMirrorStarred as independent triggers in the
scheduler: autoMirror covers owned (and self-starred) repos, autoMirrorStarred
covers repos starred from other owners. Either flag on its own is enough
to enter the auto-mirror phase, and the filter scopes the work accordingly.
Also normalize the owner comparison to lowercase since GitHub usernames are
case-insensitive — previously a self-starred repo whose stored owner casing
differed from the configured owner would be misclassified as a third-party
star.
Behavior change worth flagging in release notes: anyone who currently has
the starred checkbox on (broken state) will start getting starred repos
mirrored on upgrade. AUTO_MIRROR_REPOS=true users see no change.
* fix: hoist migrateSucceeded above try so catch can update DB on failure (fixes#268)
`let migrateSucceeded` was declared inside the try block of
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg, but the catch
block referenced it. Block-scoping made it invisible to catch, so any
error inside the try (network timeout, transient 5xx, etc.) crashed the
catch with `ReferenceError: migrateSucceeded is not defined` before
reaching the DB update that marks the repo "failed". Result: repos
stuck in "mirroring" forever with no entry in the activity log.
Hoisting the declaration above the try restores the intended behavior:
catch updates the repo to failed, clears mirroredLocation when migrate
hadn't succeeded, writes a failed activity-log entry, and re-throws
with the original error message preserved.
TypeScript was flagging this as "Cannot find name 'migrateSucceeded'"
but esbuild stripped the types during build, so the bug shipped.
* test: replace integration test with structural source check (#268)
The behavioral version of this regression test passed locally but
failed in CI because of mock.module pollution between files: bun's
mock.module is process-wide, so my mock for @/lib/gitea-enhanced
leaked into gitea-enhanced.test.ts (its real-module assertions saw
my null-returning mocks), and gitea-enhanced.test.ts's own
@/lib/http-client mock could supersede mine depending on file
discovery order, causing my mirrorGithubRepoToGitea call to not
throw at all in CI.
Replace with a structural assertion that reads gitea.ts and verifies
`let migrateSucceeded` is declared before the outermost try in both
mirrorGithubRepoToGitea and mirrorGitHubRepoToGiteaOrg. Verified the
new test fails on the pre-fix source with a clear error message
pointing to issue #268, and passes on the fixed source.
Forgejo < 15.0.0 silently discards auth_username/auth_password sent to
/api/v1/repos/migrate, causing subsequent pull-mirror sync of private repos
to fail with `terminal prompts disabled`. Fix landed upstream in Forgejo
v15.0.0 via codeberg.org/forgejo/forgejo/pulls/11909 and was not backported
to v12/v13/v14.
Test-connection endpoint now also probes /api/v1/version, detects Forgejo
via the `+gitea-` suffix, and surfaces a warning Alert in the Gitea config
form when the connected server reports a major version below 15.
The useConfigStatus hook treated `githubConfig.username` and
`giteaConfig.username` as required for the dashboard to render. In
practice neither is required at runtime — the GitHub token is
self-authenticating via listForAuthenticatedUser, and a Gitea username
isn't needed under single-org or flat mirror strategies.
Users who configured via env vars without GITHUB_USERNAME / GITEA_USERNAME
set (or who left those blank in the form, which is only client-side
`required`) ended up with empty strings in their config row. Mirroring
ran fine — tokens alone are sufficient — but the dashboard refused to
fetch and rendered all zeros because useConfigStatus failed the gate.
Drop the username checks from the gate. The `githubOwner` field is still
exported for consumers that want to display an owner; only the gate is
relaxed. Cache-hit and fresh-fetch branches both updated.
Multiple "select from configs where userId" queries had no ORDER BY,
so when a user's database accidentally contained more than one config
row for the same user (e.g. from an env-loader insert path or a partial
default-config create), SQLite returned a non-deterministic row.
In the reported case this caused /api/config to hand back an empty stub
while /api/dashboard's repo/org counts came from the populated active
row. The dashboard's useConfigStatus hook then saw missing username/
token, treated config as incomplete, and never fetched dashboard data —
the UI rendered with all zeros even though 868 repos were sitting in
the database, mirroring fine in the background.
Add `ORDER BY isActive DESC, updatedAt DESC` before LIMIT 1 to every
"fetch the user's config" query so the active and most-recently-updated
row consistently wins. Also order env-config-loader's first-user pick
by createdAt for deterministic behavior across restarts.
Already-safe call sites that explicitly filter on isActive=true or
iterate all active configs (cleanup/scheduler/repositories/orgs/cleanup
trigger/sync-organization) are left unchanged.
Updates the mirror-repo test mock to match the new orderBy().limit()
chain.
Closes#271
* fix: honor GH_API_URL across all Octokit call sites
Six Octokit call sites constructed `new Octokit(...)` directly instead of
going through `createGitHubClient()`, so `GH_API_URL` (and the
`GITHUB_API_URL` fallback) only applied to the handful of flows that used
the helper. For GHES / GHEC-with-data-residency users this surfaced most
visibly as the "Test Connection" button hitting `api.github.com/user`
and failing with 401 even when `GH_API_URL` was set correctly (#269).
Route everything through `createGitHubClient()`:
- src/pages/api/github/test-connection.ts (the reported failure)
- src/pages/api/sync/repository.ts (public-repo sync)
- src/lib/gitea-enhanced.ts (force-push detection + metadata octokit)
- src/lib/scheduler-service.ts (auto-discovery, auto-mirror, auto-start)
- src/tests/test-metadata-mirroring.ts (dev harness, for consistency)
Side benefit: scheduler + sync paths now also get throttling, rate-limit
tracking, and the standard User-Agent, which they were missing.
`createGitHubClient`'s `token` parameter is made optional so the
public-repo sync path (`new Octokit()` with no auth) can keep working.
Fixes#269
* fix: address review findings
- scheduler: pass config.githubConfig?.owner (the real DB field) instead
of ?.username, which doesn't exist on the DB row and was silently
resolving to undefined — matches every other DB-reading call site.
- sync/repository.ts: revert to bare Octokit for the unauthenticated
public-repo lookup to preserve fast-fail on the 60 req/hr limit.
Still reads GH_API_URL / GITHUB_API_URL inline so GHES / GHEC
data-residency users benefit. The throttling plugin's retry-with-
backoff is wrong UX for a one-shot button click.
- github.ts: revert createGitHubClient token back to required (no
remaining callers pass undefined after the above).
- gitea-enhanced.ts: make the leftover Octokit import type-only.
- test-connection.test.ts: replace mid-test mock.module re-call with a
mutable stub reference — safer against ESM live-binding semantics.
- Add metadata state guards for issues/PRs in syncGiteaRepoEnhanced to
prevent re-mirroring on every sync (fixes#262)
- Fix httpPut → httpPatch for Gitea release updates (HTTP 405 error)
- Add paginated release retention cleanup to enforce releaseLimit by
deleting the oldest excess releases (fixes#264)
- Always send auth credentials during migration for all repos, not just
private ones, preventing "terminal prompts disabled" on Forgejo (fixes#263)
- Add missing service: "git" to org migration payload
- Make buildGithubSourceAuthPayload return {} instead of throwing when
token is missing, allowing unconditional use
- Preserve existing backup settings in env-config-loader so UI-configured
backup strategy and retention values survive restart (fixes#267)
Closes#262, #263, #264, #267
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: improve reverse proxy support for subdomain deployments (#63)
- Add X-Accel-Buffering: no header to SSE endpoint to prevent Nginx
from buffering the event stream
- Auto-detect trusted origin from Host/X-Forwarded-* request headers
so the app works behind a proxy without manual env var configuration
- Add prominent reverse proxy documentation to advanced docs page
explaining BETTER_AUTH_URL, PUBLIC_BETTER_AUTH_URL, and
BETTER_AUTH_TRUSTED_ORIGINS are mandatory for proxy deployments
- Add reverse proxy env var comments and entries to both
docker-compose.yml and docker-compose.alt.yml
- Add dedicated reverse proxy configuration section to .env.example
* fix: address review findings for reverse proxy origin detection
- Fix x-forwarded-proto multi-value handling: take first value only
and validate it is "http" or "https" before using
- Update comment to accurately describe auto-detection scope: helps
with per-request CSRF checks but not callback URL validation
- Restore startup logging of static trusted origins for debugging
* fix: handle multi-value x-forwarded-host in chained proxy setups
x-forwarded-host can be comma-separated (e.g. "proxy1.example.com,
proxy2.example.com") in chained proxy setups. Take only the first
value, matching the same handling already applied to x-forwarded-proto.
* test: add unit tests for reverse proxy origin detection
Extract resolveTrustedOrigins into a testable exported function and
add 11 tests covering:
- Default localhost origins
- BETTER_AUTH_URL and BETTER_AUTH_TRUSTED_ORIGINS env vars
- Invalid URL handling
- Auto-detection from x-forwarded-host + x-forwarded-proto
- Multi-value header handling (chained proxy setups)
- Invalid proto rejection (only http/https allowed)
- Deduplication
- Fallback to host header when x-forwarded-host absent
* fix: prevent starred repo name collisions during concurrent mirroring (#95)
When multiple starred repos share the same short name (e.g. alice/dotfiles
and bob/dotfiles), concurrent batch mirroring could cause 409 Conflict
errors because generateUniqueRepoName only checked Gitea via HTTP, missing
repos that were claimed in the local DB but not yet created remotely.
Three fixes:
- Add DB-level check in generateUniqueRepoName so it queries the local
repositories table for existing mirroredLocation claims, preventing two
concurrent jobs from picking the same target name.
- Clear mirroredLocation on failed mirror so a failed repo doesn't falsely
hold a location that was never successfully created, which would block
retries and confuse the uniqueness check.
- Extract isMirroredLocationClaimedInDb helper for the DB lookup, using
ne() to exclude the current repo's own record from the collision check.
* fix: address review findings for starred repo name collision fix
- Make generateUniqueRepoName immediately claim name by writing
mirroredLocation to DB, closing the TOCTOU race window between
name selection and the later status="mirroring" DB update
- Add fullName validation guard (must contain "/")
- Make isMirroredLocationClaimedInDb fail-closed (return true on
DB error) to be conservative about preventing collisions
- Scope mirroredLocation clear on failure to starred repos only,
preserving it for non-starred repos that may have partially
created in Gitea and need the location for recovery
* fix: address P1/P2 review findings for starred repo name collision
P1a: Remove early name claiming from generateUniqueRepoName to prevent
stale claims on early return paths. The function now only checks
availability — the actual claim happens at the status="mirroring" DB
write (after both idempotency checks), which is protected by a new
unique partial index.
P1b: Add unique partial index on (userId, mirroredLocation) WHERE
mirroredLocation != '' via migration 0010. This enforces atomicity at
the DB level: if two concurrent workers try to claim the same name,
the second gets a constraint violation rather than silently colliding.
P2: Only clear mirroredLocation on failure if the Gitea migrate call
itself failed (migrateSucceeded flag). If migrate succeeded but
metadata mirroring failed, preserve the location since the repo
physically exists in Gitea and we need it for recovery/retry.
* 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.
SQLite rejects ALTER TABLE ADD COLUMN with expression defaults like
DEFAULT (unixepoch()), which Drizzle-kit generated for the imported_at
column. This broke upgrades from v3.12.x to v3.13.0 (#228, #229).
Changes:
- Rewrite migration 0009 using table-recreation pattern (CREATE, INSERT
SELECT, DROP, RENAME) instead of ALTER TABLE
- Add migration validation script with SQLite-specific lint rules that
catch known invalid patterns before they ship
- Add upgrade-path testing with seeded data and verification fixtures
- Add runtime repair for users whose migration record may be stale
- Add explicit migration validation step to CI workflow
Fixes#228Fixes#229
* 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
* fix(nix): ensure absolute bundle path in pre-sync backup (#203)
Use path.resolve() instead of conditional path.isAbsolute() check to
guarantee bundlePath is always absolute before passing to git -C. On
NixOS, relative paths were interpreted relative to the temp mirror
clone directory, causing "No such file or directory" errors.
Closes#203
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(nix): ensure absolute bundle path in pre-sync backup (#203)
Use path.resolve() instead of conditional path.isAbsolute() check to
guarantee bundlePath is always absolute before passing to git -C. On
NixOS, relative paths were interpreted relative to the temp mirror
clone directory, causing "No such file or directory" errors.
Extract resolveBackupPaths() for testability. Bump version to 3.10.1.
Closes#203
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: drop macos matrix and only run nix build on main/tags
- Remove macos-latest from Nix CI matrix (ubuntu-only)
- Only run `nix build` on main branch and version tags, skip on PRs
- `nix flake check` still runs on all PRs for validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* 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>
* feat: add E2E testing infrastructure with fake GitHub, Playwright, and CI workflow
- Add fake GitHub API server (tests/e2e/fake-github-server.ts) with
management API for seeding test data
- Add Playwright E2E test suite covering full mirror workflow:
service health checks, user registration, config, sync, verify
- Add Docker Compose for E2E Gitea instance
- Add orchestrator script (run-e2e.sh) with cleanup
- Add GitHub Actions workflow (e2e-tests.yml) with Gitea service container
- Make GITHUB_API_URL configurable via env var for testing
- Add npm scripts: test:e2e, test:e2e:ci, test:e2e:keep, test:e2e:cleanup
* feat: add real git repos + backup config testing to E2E suite
- Create programmatic test git repos (create-test-repos.ts) with real
commits, branches (main, develop, feature/*), and tags (v1.0.0, v1.1.0)
- Add git-server container to docker-compose serving bare repos via
dumb HTTP protocol so Gitea can actually clone them
- Update fake GitHub server to emit reachable clone_url fields pointing
to the git-server container (configurable via GIT_SERVER_URL env var)
- Add management endpoint POST /___mgmt/set-clone-url for runtime config
- Update E2E spec with real mirroring verification:
* Verify repos appear in Gitea with actual content
* Check branches, tags, commits, file content
* Verify 4/4 repos mirrored successfully
- Add backup configuration test suite:
* Enable/disable backupBeforeSync config
* Toggle blockSyncOnBackupFailure
* Trigger re-sync with backup enabled and verify activities
* Verify config persistence across changes
- Update CI workflow to use docker compose (not service containers)
matching the local run-e2e.sh approach
- Update cleanup.sh for git-repos directory and git-server port
- All 22 tests passing with real git content verification
* refactor: split E2E tests into focused files + add force-push tests
Split the monolithic e2e.spec.ts (1335 lines) into 5 focused spec files
and a shared helpers module:
helpers.ts — constants, GiteaAPI, auth, saveConfig, utilities
01-health.spec.ts — service health checks (4 tests)
02-mirror-workflow.spec.ts — full first-mirror journey (8 tests)
03-backup.spec.ts — backup config toggling (6 tests)
04-force-push.spec.ts — force-push simulation & backup verification (9 tests)
05-sync-verification.spec.ts — dynamic repos, content integrity, reset (5 tests)
The force-push tests are the critical addition:
F0: Record original state (commit SHAs, file content)
F1: Rewrite source repo history (simulate force-push)
F2: Sync to Gitea WITHOUT backup
F3: Verify data loss — LICENSE file gone, README overwritten
F4: Restore source, re-mirror to clean state
F5: Enable backup, force-push again, sync through app
F6: Verify Gitea reflects the force-push
F7: Verify backup system was invoked (snapshot activities logged)
F8: Restore source repo for subsequent tests
Also added to helpers.ts:
- GiteaAPI.getBranch(), .getCommit(), .triggerMirrorSync()
- getRepositoryIds(), triggerMirrorJobs(), triggerSyncRepo()
All 32 tests passing.
* Try to fix actions
* Try to fix the other action
* Add debug info to check why e2e action is failing
* More debug info
* Even more debug info
* E2E fix attempt #1
* E2E fix attempt #2
* more debug again
* E2E fix attempt #3
* E2E fix attempt #4
* Remove a bunch of debug info
* Hopefully fix backup bug
* Force backups to succeed