Analysis of why Gitea / Forgejo / Codeberg / Gogs sync can issue thousands of HTTP requests, which faster API paths already exist in the codebase, and how to measure them before changing production behavior.
GitSyncMarks uses the Contents API as the primary read/write path for Gitea-family providers (writeStrategy: 'contents' in lib/git-provider-common.js):
| Operation | Current path | Request pattern |
|---|---|---|
| Read | fetchFileMapViaContents → _walkContentsForFileMap |
~1 request per directory + ~1 per file (sequential) |
| Write | GiteaAPI.atomicCommit → _atomicCommitViaGitData (fallback: _atomicCommitSequential) |
1 commit per push when git data succeeds; fallback ~1 request and 1 commit per file |
GitHub uses Git Data API instead: one recursive tree listing + batched blob GETs (reads), layered POST /git/trees + one commit (writes). See docs/SYNC-LOGIC.md § Optimized Remote Fetching.
Large bookmark sets (thousands of JSON files) therefore feel slow on Codeberg even with good network connectivity.
Documented in CHANGELOG.md (3.0.x):
- Reads:
git/treesmetadata was unreliable on some self-hosted instances (Commit … has no tree SHA). - Writes: Batch “Change Files” endpoints returned misleading 401 on empty or older Forgejo builds.
- After Contents writes, tree SHA in responses was sometimes missing → Contents reads stabilized post-push state.
The adapter still implements tree helpers (getRecursiveTreeForCommit, _getPathShaMap) for SHA lookup during writes; they are not used for pull/sync reads today.
Script: scripts/benchmark-gitea-sync.js
# Analytical table only (no credentials)
npm run test:gitea-benchmark:estimate
# Live read + write — variables in `.env` (auto-loaded) or shell
# See .env.example for GITSYNCMARKS_GITEA_* keys (Codeberg token from extension profile).
npm run test:gitea-benchmark
# Read-only (no temporary write files)
node scripts/benchmark-gitea-sync.js --read-only --write-count 0
# Small write test (creates/cleans bookmarks/_gitsyncmarks_bench_*)
node scripts/benchmark-gitea-sync.js --write-count 10
# Save JSON report
node scripts/benchmark-gitea-sync.js --read-only --output gitea-bench-report.jsonEnvironment variables are listed in .env.example.
Reads
- Contents — production path (
fetchFileMapViaContents). - Tree + blob —
getRecursiveTreeForCommit+gitTreeToShaMap+ batchedgetBlob(5 concurrent, same aslib/remote-fetch.js). - Tree + delta — warm-cache simulation: only ~5% of blob SHAs changed (typical incremental sync).
Writes (optional, --write-count N)
- Gitea blob+tree — production
GiteaAPI.atomicCommit(batched blobs + SHA tree layers, one commit). - GitHub inline tree —
GitHubAPI.prototype.atomicCommiton aGiteaAPIinstance (expected 404 on Codeberg). - Contents sequential —
_atomicCommitSequentialfallback (N commits).
Temporary benchmark files are removed via a final tree cleanup commit when possible.
From npm run test:gitea-benchmark:estimate (formulas in the script; no live host):
| Files | Contents read (req) | Tree+blob read (req) | Tree+delta 5% (req) | Contents write (req / commits) | Tree write (req / commits) |
|---|---|---|---|---|---|
| 10 | 11 | 11 | 6 | 10 / 10 | 3 / 1 |
| 100 | 105 | 101 | 6 | 100 / 100 | 3 / 1 |
| 500 | 525 | 501 | 26 | 500 / 500 | 4 / 1 |
| 1 000 | 1 050 | 1 001 | 51 | 1 000 / 1 000 | 5 / 1 |
| 5 000 | 5 250 | 5 001 | 251 | 5 000 / 5 000 | 15 / 1 |
Notes:
- Read: Tree+blob still downloads every blob on a cold cache (similar request count to Contents), but runs batched parallel GETs and a single tree listing — lower wall-clock time and simpler server load pattern.
- Read delta: With
lastSyncFilescache (real sync), tree path only fetches changed blobs → ~1 tree + O(changed) blob GETs. - Write: Tree path is orders of magnitude fewer requests and one commit vs thousands of commits; this is the largest practical win for transfer, profile switch push, and first-time push.
Live numbers (requests, milliseconds, success/failure) should be recorded by running the script against each target host and appending to the “Live results” section below.
| Host | Read Contents | Read Tree+Blob | Write Contents | Write Blob+Tree | Notes |
|---|---|---|---|---|---|
| codeberg.org | Works | Works (Phase 1) | Works (fallback) | Works (Phase 2) — 10 files / 33 req / 18s / 1 commit | Forgejo; primary user target |
| Self-hosted Gitea ≥ 1.19 | Works | Often yes | Works (fallback) | Verify live | Same blob+tree path as Codeberg |
| Self-hosted Forgejo | Works | Often yes | Works (fallback) | Verify live | Same adapter as Gitea |
| Gogs | Works | Variable — older git metadata | Works | Variable | Fallback to Contents remains required |
| Empty repo | Works | Tree may be empty | Contents works | Tree createRef path — verify |
Empty-repo edge cases motivated Contents writes |
Legend: Works = used in production today. Expected yes / Verify live = infrastructure exists in code; run benchmark script to confirm before switching default.
Add rows after running npm run test:gitea-benchmark with --output.
| Date | Host | Repo files | Contents read (req/ms) | Tree read (req/ms) | Tree+delta 5% (req/ms) | Blob+tree write 10 files | Contents write 10 files | Inline tree 10 files |
|---|---|---|---|---|---|---|---|---|
| 2026-06-06 (combined) | codeberg.org | 686 | 724 / 232s | 646 / 9.4s (1 blob fail) | — | rate-limited | rate-limited | — |
| 2026-06-06 (read-only) | codeberg.org | 686 | 724 / 223s | 687 / 12s | 36 / 0.8s | — | — | — |
| 2026-06-06 (write-only, pre-Phase 2) | codeberg.org | — | — | — | — | — | 25 req / 11.6s, 10 commits | 404 inline content |
| 2026-06-06 (write-only, Phase 2) | codeberg.org | — | — | — | — | 33 req / 18.2s, 1 commit | 20 req / 18.6s, 10 commits | 404 (4 req / 0.6s) |
Interpretation (2026-06-06, confirmed):
- Reads — try-first tree+blob is viable on Codeberg: same 686 files, 18.8× faster wall-clock (223s → 12s). Request count similar (~724 vs ~687), but 5 parallel blob GETs replace the sequential Contents directory walk.
- Incremental read (warm cache): simulated 5% change → 36 requests / 0.8s vs full Contents pull 223s. Matches real sync when
lastSyncFilesSHA cache is warm. - Writes (Phase 2) — blob+tree works on Codeberg:
GiteaAPI.atomicCommit→ 10 files in one commit (33 requests / 18.2s). Contents sequential fallback: 20 requests / 18.6s / 10 commits. At 10 files, wall-clock is similar (network-bound); the win is commit count (1 vs N), which matters for history, webhooks, and large pushes. - Writes — GitHub inline tree still fails:
GitHubAPI.atomicCommitwithcontentonPOST /git/trees→ HTTP 404 on Codeberg (580ms, 4 requests before failure).
Git Data try-first + Contents fallback — two phases:
Implemented in lib/remote-fetch.js buildRemoteMaps():
getRecursiveTreeForCommit→gitTreeToShaMap→fetchBlobsBatched(same as GitHub/GitLab).- On failure, truncated tree, or empty
basePathlisting →fetchFileMapViaContentsfallback. - Truncated-tree guard (
api_treeTruncated) unchanged.
Benchmark result (Codeberg 2026-06-06): inherited GitHubAPI.atomicCommit() (inline content on POST /git/trees) returns HTTP 404. Production path uses blob SHAs instead.
In lib/providers/gitea-api.js atomicCommit():
_atomicCommitViaGitData()— batchedPOST /git/blobs(concurrency 5) +chunkAtomicCommitShaTreeBatches+ layeredPOST /git/trees+POST /git/commits+ ref update (one commit).- On failure (401/404/405/422/501 or “modified in the meantime”) →
_atomicCommitSequential()(Contents API).
Re-run npm run test:gitea-benchmark --write-only --write-count 10 on Codeberg to record live blob+tree numbers in the matrix below.
| Option | Reason |
|---|---|
| Parallel Contents writes | Each Contents PUT still creates a separate git commit → polluted history |
| Archive zip download | One request but heavy parse/memory; no incremental delta |
| Parallel Contents reads only | Helps wall-clock slightly; still O(N) requests |
- Probe tree read/write during connection test; store
gitDataApiSupportedon profile. - Log which path was used (
treevscontents) in debug mode for support. Shipped:console.warnon Contents API fallback inGiteaAPI.atomicCommitandcommitBookmarkChanges.
- Push 1 000 files on Codeberg: wall-clock < 2 min via tree, or clear fallback without data loss.
- Pull with warm cache: < 10 blob GETs for typical 5% change rate.
- Self-hosted instance without tree API: automatic Contents fallback, same as today.
- No regression on truncated-tree or empty-repo flows.
- PROVIDERS.md — Gitea-family write/read strategy
- SYNC-LOGIC.md — GitHub optimized fetch (target model for Gitea)
- ARCHITECTURE.md —
github-tree-batch.js,remote-fetch.js