From 7ee89b561b12f32af001094aaef8101654fdb12f Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 7 May 2026 10:20:35 +0200 Subject: [PATCH] fix(backup): Gitea/Forgejo handle list-shaped ref response and empty-repo bootstrap (issue #1224 and #1225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interacting bugs in the Gitea/Forgejo backend, both inherited from GitHubBackend because PR #1160 assumed Gitea's Git Data API was fully GitHub-compatible. It isn't, on two specific points: 1. List-shaped ref response. Gitea/Forgejo's GET /api/v1/repos/{owner}/{repo}/git/refs/heads/{branch} returns a GET /api/v1/repos/{owner}/{repo}/git/refs/heads/{branch} returns a list of matching refs even when only one matches; GitHub returns a single object. The inherited push paths did ref_response.json()["object"]["sha"] and crashed with "list indices must be integers or slices, not str" against any populated Gitea repo. 2. Empty-repo writes refused. GitHub accepts blob/tree/commit POSTs against a brand-new empty repo and creates the initial commit implicitly. Gitea refuses every blob POST with 404 until the repo has at least one commit, so _create_initial_commit silently failed: blobs returned 404, tree_items stayed empty, the tree POST then also 404'd ("Failed to create tree"). Fix lives entirely in GiteaBackend — github.py is untouched so the proven GitHub path takes zero risk. GiteaBackend now overrides push_files, _create_branch_and_push, and _create_initial_commit: - _ref_sha() helper accepts both list and dict shapes; called at the two SHA extraction sites in push_files and _create_branch_and_push. - _create_initial_commit posts to Gitea's Contents API (POST /api/v1/repos/{owner}/{repo}/contents with a files array plus branch + new_branch) which seeds the initial commit + branch in one transaction and is documented to work on empty repos. ForgejoBackend extends GiteaBackend with no overrides and inherits both fixes; tests pin that. --- CHANGELOG.md | 1 + backend/app/services/git_providers/gitea.py | 262 +++++++++++++++++- backend/tests/unit/test_git_providers.py | 279 ++++++++++++++++++-- 3 files changed, 523 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc1dce74..d5a500e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file. - **Filament usage double-counted when AMS auto-falls-back to a same-material spool** ([#957](https://github.com/maziggy/bambuddy/issues/957)) — When one spool ran out mid-print and the AMS transparently switched to a sibling slot loaded with the same material, the usage tracker credited the originally-mapped spool with the full 3MF estimate AND added the fallback spool's remain%-delta on top — so a 78 g print could show as 78 g + 60 g = 138 g consumed across the two spools, leaving the empty spool's recorded weight beyond its label weight (the symptom the original report flagged on a 1209 g spool reading "1188.30 g used" while the new spool only got a 30 g credit). Two interacting bugs: (1) the tray-change recorder in `bambu_mqtt.py` gated on `state in ("RUNNING", "PAUSE")` literal strings, and P2S firmware briefly transitions out of RUNNING during the AMS swap, so the switch was never appended to `tray_change_log`; (2) the usage-tracker splitting branch in `usage_tracker.py` was gated on `not slot_to_tray`, so even when the tray-change log was populated the splitting code only ran for prints where the slicer's mapping had not been captured — i.e. never on the actual fallback case. **Fix:** the `bambu_mqtt.py` gate now keys on the print-lifecycle flags (`_was_running and not _completion_triggered`) so any tray change between print start and completion is captured regardless of the momentary `gcode_state` string. The `usage_tracker.py` gate is split so `tray_change_log` evidence with > 1 entries always takes over from `slot_to_tray`, treating the per-segment per-layer gcode usage as the source of truth when the printer actually fed from multiple trays. Path 2 (AMS remain%-delta fallback) then naturally skips both trays because they're already in `handled_trays` after splitting, eliminating the double-credit. **Tests:** new `test_tray_change_recorded_during_intermediate_state` and `test_tray_change_not_recorded_after_completion` in `test_bambu_mqtt.py` exercising the new gate; new `test_tray_switch_overrides_print_cmd_mapping` in `test_usage_tracker.py` pinning that with `ams_mapping=[0]` set and `tray_change_log=[(0,0),(1,30)]` the splitter produces two segments summing to the 3MF estimate (no double-count) and adds both `(0,0)` and `(0,1)` to `handled_trays`. - **3D Preview returned `{"detail":"Not Found"}` in Docker installs** ([#1218](https://github.com/maziggy/bambuddy/issues/1218)) — The embedded GCode viewer's static assets (`gcode_viewer/`) were not copied into the production Docker image, so clicking "3D Preview" on any archive loaded an iframe at `/gcode-viewer/?archive=` that returned a bare FastAPI 404 — Firefox / Chrome rendered the JSON response inside the iframe area while the outer Bambuddy layout looked normal, masking the failure unless the user actually inspected the iframe. The Vite production build doesn't stage `gcode_viewer/` into `static/` either (the dev server serves it via a `configureServer` middleware that's dev-only), and the only integration test for the route accepted `404` as a valid outcome ("`assert response.status_code in (200, 404)`") so CI never caught the missing files. Affected every Docker build since the embedded viewer landed in 0.2.4b1 (commit `3adce435`, 2026-04-22). **Fix:** `Dockerfile` now copies the `gcode_viewer/` directory alongside the React build output. **Defence in depth:** `backend/app/main.py` logs an ERROR at startup when `_gcode_viewer_dir / "index.html"` is missing so future packaging gaps surface in `docker logs` and the support bundle instead of as silent runtime 404s. **Test guard:** `backend/tests/integration/test_gcode_viewer.py` adds `test_gcode_viewer_index_served_when_assets_present` which skips when the directory is intentionally absent (unit-test environments) but asserts `200 OK` + a non-empty HTML body when the assets do exist on disk — so a future broken `COPY` fails CI loudly rather than continuing to ship a broken image. - **Slice button no longer enabled before the preview slice resolves** — Until the preview slice (or embedded-metadata read for already-sliced 3MFs) returned the per-plate filament list, the SliceModal rendered a synthetic single-slot fallback so the auto-pick had something to bind against. That made the Slice button enabled the moment the modal opened, even before the slicer had told us which AMS slots the plate actually consumes — clicking would dispatch against opaque defaults and the real-life print would either pick the wrong filament or fail with a slot-mismatch error after the fact. Adds `filamentReqsQuery.isSuccess` to the `isReady` chain so the button stays disabled while the preview slice is in flight (or before the backend's `/filament-requirements` call settles for sliced files) and flips to enabled the moment the real slot list lands and auto-pick fills it. +- **Backups to Gitea / Forgejo failed with "Failed to create tree" on empty repos and "list indices must be integers or slices, not str" on populated repos** ([#1224](https://github.com/maziggy/bambuddy/issues/1224), [#1225](https://github.com/maziggy/bambuddy/issues/1225)) — Two interacting bugs in the Gitea/Forgejo backend, both inherited from `GitHubBackend` because PR #1160's class docstring assumed Gitea's Git Data API was fully GitHub-compatible. (1) **List-shaped ref response:** `GET /api/v1/repos/{owner}/{repo}/git/refs/heads/{branch}` returns a *list* of matching refs on Gitea/Forgejo even when only one matches (`[{"ref": ..., "object": {"sha": ...}}]`), whereas GitHub returns a single object. The inherited `push_files` and `_create_branch_and_push` did `ref_response.json()["object"]["sha"]` and crashed with `list indices must be integers or slices, not str` — surfacing as the failure at the top of any push against a populated Gitea repo (#1225's symptom, and #1224's symptom once the user committed any file before the first backup). (2) **Empty-repo writes refused:** GitHub's Git Data API accepts `POST /git/blobs` against a brand-new empty repo and creates the initial commit + branch implicitly. Gitea refuses every blob/tree/commit POST with 404 until the underlying git repo has at least one commit — so the inherited `_create_initial_commit` (which posts blobs → tree → commit → ref in that order) silently failed: every blob POST returned 404, `tree_items` ended up empty, and the next tree POST also returned 404 ("Failed to create tree" — #1224's symptom on a freshly-created empty Gitea repo). **Fix:** `GiteaBackend` now overrides `push_files`, `_create_branch_and_push`, and `_create_initial_commit` directly instead of inheriting them. The Git Data API path uses a `_ref_sha()` helper that accepts both list and dict shapes; the empty-repo bootstrap route uses Gitea's Contents API (`POST /api/v1/repos/{owner}/{repo}/contents` with a `files` array, `branch=`, `new_branch=`) which seeds the initial commit + branch in a single transaction — Contents API is documented to work on empty repos because it goes through Gitea's higher-level repo-init path. `GitHubBackend` is **untouched** — the GitHub backup path is proven working, the fix is fully isolated to the Gitea side. `ForgejoBackend(GiteaBackend)` inherits both fixes automatically; tests pin that. **Tests:** 10 new tests in `test_git_providers.py` — `TestGiteaBackendListShapeRefResponse` (4 tests: `_ref_sha` accepts list/dict/empty-list, plus full `push_files` happy paths against list-shaped branch ref and list-shaped default-branch ref), `TestGiteaBackendEmptyRepoInitialCommit` (4 tests: empty repo routes through Contents API exclusively with no blob/tree/commit/ref Git Data API calls, payload shape verified field-by-field against Gitea's documented schema, error truncation works, empty file dict returns `skipped` without firing a useless API call), and `TestForgejoInheritsGiteaFixes` (2 tests: list-shape and empty-repo paths both work via inheritance). Existing 6 `TestGiteaBackendPushFiles` tests still pass since `_ref_sha` accepts dict-shaped responses too. Total: 78 tests pass across the backup unit + integration suites; ruff clean. - **Docker data-volume ownership normalised at startup via gosu entrypoint** ([#1211](https://github.com/maziggy/bambuddy/issues/1211)) — Two long-standing failure modes have been biting Docker users repeatedly: (1) Docker named volumes are created by the daemon as `root:root`, and the previous `chmod 777 /app/data` Dockerfile workaround only covered the named-volume root — so subdirs Bambuddy creates at runtime (`virtual_printer/uploads`, `virtual_printer/certs`, etc.) inherited wrong ownership when the container ran as `1000:1000`. (2) The shipped `docker-compose.yml` ships `./virtual_printer:/app/data/virtual_printer` uncommented, and dockerd creates a missing bind-mount source on the host as root before the container starts — leaving the host directory unwritable by uid 1000 inside the container even though the named volume above it had the chmod-777 workaround. Symptom either way: `[Errno 13] Permission denied: '/app/data/virtual_printer/uploads'`, no virtual printer ever starts, "VP doesn't work" support reports follow. **Replaces the chmod-777 hack with a proper entrypoint:** `deploy/docker-entrypoint.sh` runs as root, chowns `/app/data` and `/app/logs` (and `/app/data/virtual_printer` when bind-mounted) to `PUID:PGID`, then drops to that uid via `gosu` before `exec`'ing the app. The chown is gated behind a top-level ownership check so subsequent restarts skip the recursive traversal — no multi-second startup penalty on multi-GB archive directories. A sentinel `.bambuddy` file in each data path prevents Docker from re-syncing image directory metadata on every mount (otherwise empty volumes have their ownership reverted from the image on each restart, defeating the idempotency). When the container is started with an explicit `user:` directive or `--user` flag the entrypoint detects it isn't root and falls through to direct `exec` — preserving compatibility for users who pin a specific uid. **Compose template changes:** removes `user: "${PUID:-1000}:${PGID:-1000}"` (the entrypoint owns privilege drop now), adds `PUID` / `PGID` env vars with the same defaults, and comments out the `./virtual_printer:/app/data/virtual_printer` bind mount by default with explicit "only needed if you also run a native install of Bambuddy on the same host and want both to share the VP CA cert" guidance. The entrypoint chowns the host-side dir through the bind mount the first time it sees wrong ownership, so existing uncommented installs continue to work and #1211 specifically gets fixed. ## [0.2.4b2] - 2026-05-05 diff --git a/backend/app/services/git_providers/gitea.py b/backend/app/services/git_providers/gitea.py index e0e835341..5ca88f842 100644 --- a/backend/app/services/git_providers/gitea.py +++ b/backend/app/services/git_providers/gitea.py @@ -1,18 +1,45 @@ -"""Gitea backend — uses the Git Data API inherited from GitHubBackend.""" +"""Gitea backend — overrides GitHubBackend where Gitea's API diverges.""" +import base64 +import json +import logging import re +from datetime import datetime, timezone + +import httpx from backend.app.services.git_providers.github import GitHubBackend +logger = logging.getLogger(__name__) + class GiteaBackend(GitHubBackend): """Backend for Gitea instances. - Gitea's Git Data API (/api/v1/repos/{owner}/{repo}/git/...) is compatible - with GitHub's, so push_files, _create_branch_and_push, and _create_initial_commit - are inherited unchanged. Only the API base URL and Accept header differ. + Gitea's Git Data API (/api/v1/repos/{owner}/{repo}/git/...) is *mostly* + compatible with GitHub's, but diverges on two points that broke real-world + backups (#1224, #1225): + + 1. ``GET /git/refs/heads/{branch}`` returns a *list* of matching refs even + when only one matches; GitHub returns a single object. The push paths + below extract the SHA via ``_ref_sha()`` instead of the GitHub-style + ``["object"]["sha"]`` chain. + + 2. The Git Data API (blobs/trees/commits/refs) refuses writes against an + empty repository — every blob POST returns 404 until the repo has at + least one commit. ``_create_initial_commit()`` is overridden to use the + Contents API, which seeds the branch + initial commit in a single call. """ + @staticmethod + def _ref_sha(ref_data) -> str: + """Extract the commit SHA from Gitea's list-shaped ref response.""" + if isinstance(ref_data, list): + if not ref_data: + raise ValueError("Empty refs list returned by Gitea API") + return ref_data[0]["object"]["sha"] + return ref_data["object"]["sha"] + def parse_repo_url(self, url: str) -> tuple[str, str]: """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances.""" if not url or len(url) > 500: @@ -42,3 +69,230 @@ class GiteaBackend(GitHubBackend): headers = super().get_headers(token) headers["Accept"] = "application/json" return headers + + async def push_files( + self, + repo_url: str, + token: str, + branch: str, + files: dict, + client: httpx.AsyncClient, + ) -> dict: + """Push files via the Git Data API, normalising Gitea's list-shaped ref response.""" + try: + owner, repo = self.parse_repo_url(repo_url) + api_base = self.get_api_base(repo_url) + headers = self.get_headers(token) + + ref_response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", headers=headers) + + if ref_response.status_code == 404: + return await self._create_branch_and_push( + client, headers, api_base, owner, repo, branch, files, repo_url, token + ) + + if ref_response.status_code != 200: + return { + "status": "failed", + "message": f"Failed to get branch ref: {ref_response.status_code}", + "error": self._truncated_response_text(ref_response), + } + + current_commit_sha = self._ref_sha(ref_response.json()) + + commit_response = await client.get( + f"{api_base}/repos/{owner}/{repo}/git/commits/{current_commit_sha}", headers=headers + ) + if commit_response.status_code != 200: + return {"status": "failed", "message": "Failed to get current commit"} + + current_tree_sha = commit_response.json()["tree"]["sha"] + + tree_response = await client.get( + f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers + ) + existing_files: dict[str, str] = {} + if tree_response.status_code == 200: + for item in tree_response.json().get("tree", []): + if item["type"] == "blob": + existing_files[item["path"]] = item["sha"] + + tree_items = [] + files_changed = 0 + + for path, content in files.items(): + content_str = json.dumps(content, indent=2, default=str) + content_bytes = content_str.encode("utf-8") + content_sha = self._blob_sha(content_bytes) + + if path in existing_files and existing_files[path] == content_sha: + continue + + blob_response = await client.post( + f"{api_base}/repos/{owner}/{repo}/git/blobs", + headers=headers, + json={"content": base64.b64encode(content_bytes).decode(), "encoding": "base64"}, + ) + if blob_response.status_code != 201: + logger.error("Failed to create blob for %s: %s", path, self._truncated_response_text(blob_response)) + continue + + tree_items.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_response.json()["sha"]}) + files_changed += 1 + + if not tree_items: + return {"status": "skipped", "message": "No changes to commit", "commit_sha": None, "files_changed": 0} + + tree_response = await client.post( + f"{api_base}/repos/{owner}/{repo}/git/trees", + headers=headers, + json={"base_tree": current_tree_sha, "tree": tree_items}, + ) + if tree_response.status_code != 201: + return { + "status": "failed", + "message": f"Failed to create tree: {self._truncated_response_text(tree_response)}", + } + + new_tree_sha = tree_response.json()["sha"] + commit_message = f"Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}" + commit_response = await client.post( + f"{api_base}/repos/{owner}/{repo}/git/commits", + headers=headers, + json={"message": commit_message, "tree": new_tree_sha, "parents": [current_commit_sha]}, + ) + if commit_response.status_code != 201: + return { + "status": "failed", + "message": f"Failed to create commit: {self._truncated_response_text(commit_response)}", + } + + new_commit_sha = commit_response.json()["sha"] + + ref_update = await client.patch( + f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{branch}", + headers=headers, + json={"sha": new_commit_sha}, + ) + if ref_update.status_code != 200: + return { + "status": "failed", + "message": f"Failed to update branch: {self._truncated_response_text(ref_update)}", + } + + return { + "status": "success", + "message": f"Backup successful - {files_changed} files updated", + "commit_sha": new_commit_sha, + "files_changed": files_changed, + } + + except Exception as e: + logger.error("Push to Git failed: %s", e) + return {"status": "failed", "message": str(e), "error": str(e)} + + async def _create_branch_and_push( + self, + client: httpx.AsyncClient, + headers: dict, + api_base: str, + owner: str, + repo: str, + branch: str, + files: dict, + repo_url: str, + token: str, + ) -> dict: + """Create branch (from default branch or as initial commit) then push.""" + try: + repo_response = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers) + if repo_response.status_code != 200: + return {"status": "failed", "message": "Failed to get repo info"} + + default_branch = repo_response.json().get("default_branch", "main") + + ref_response = await client.get( + f"{api_base}/repos/{owner}/{repo}/git/refs/heads/{default_branch}", headers=headers + ) + if ref_response.status_code != 200: + return await self._create_initial_commit(client, headers, api_base, owner, repo, branch, files) + + base_sha = self._ref_sha(ref_response.json()) + + create_ref = await client.post( + f"{api_base}/repos/{owner}/{repo}/git/refs", + headers=headers, + json={"ref": f"refs/heads/{branch}", "sha": base_sha}, + ) + if create_ref.status_code != 201: + return { + "status": "failed", + "message": f"Failed to create branch: {self._truncated_response_text(create_ref)}", + } + + return await self.push_files(repo_url, token, branch, files, client) + + except Exception as e: + return {"status": "failed", "message": str(e)} + + async def _create_initial_commit( + self, + client: httpx.AsyncClient, + headers: dict, + api_base: str, + owner: str, + repo: str, + branch: str, + files: dict, + ) -> dict: + """Seed an empty Gitea repository via the Contents API. + + Gitea's Git Data API requires the repository to have at least one + commit before it accepts blob/tree/commit writes; on an empty repo + every ``POST /git/blobs`` returns 404. The Contents API is the + documented bootstrap path: a single ``POST /repos/{owner}/{repo}/contents`` + with a ``files`` array creates the initial commit and the target + branch in one round-trip (Gitea 1.18+, Forgejo all versions). + """ + try: + if not files: + return {"status": "skipped", "message": "No files to commit", "commit_sha": None, "files_changed": 0} + + api_files = [] + for path, content in files.items(): + content_str = json.dumps(content, indent=2, default=str) + content_b64 = base64.b64encode(content_str.encode("utf-8")).decode() + api_files.append({"operation": "create", "path": path, "content": content_b64}) + + commit_message = f"Initial Bambuddy backup - {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}" + body = { + "branch": branch, + "new_branch": branch, + "message": commit_message, + "files": api_files, + } + + response = await client.post( + f"{api_base}/repos/{owner}/{repo}/contents", + headers=headers, + json=body, + ) + + if response.status_code not in (200, 201): + return { + "status": "failed", + "message": f"Failed to create initial commit: {self._truncated_response_text(response)}", + } + + data = response.json() + commit_sha = (data.get("commit") or {}).get("sha") + return { + "status": "success", + "message": f"Initial backup created - {len(files)} files", + "commit_sha": commit_sha, + "files_changed": len(files), + } + + except Exception as e: + logger.error("Gitea initial commit failed: %s", e) + return {"status": "failed", "message": str(e), "error": str(e)} diff --git a/backend/tests/unit/test_git_providers.py b/backend/tests/unit/test_git_providers.py index 5b480a1be..c4ac62303 100644 --- a/backend/tests/unit/test_git_providers.py +++ b/backend/tests/unit/test_git_providers.py @@ -1,5 +1,6 @@ """Unit tests for the git_providers abstraction package.""" +import base64 import hashlib import json from unittest.mock import AsyncMock, MagicMock @@ -69,7 +70,10 @@ class TestGitHubBackendApiBase: assert self.backend.get_api_base("https://github.example.com/owner/repo") == "https://github.example.com/api/v3" def test_ghe_host_with_port(self): - assert self.backend.get_api_base("https://github.example.com:8443/owner/repo") == "https://github.example.com:8443/api/v3" + assert ( + self.backend.get_api_base("https://github.example.com:8443/owner/repo") + == "https://github.example.com:8443/api/v3" + ) def test_ssh_github_com_returns_api_github_com(self): assert self.backend.get_api_base("git@github.com:owner/repo.git") == "https://api.github.com" @@ -204,7 +208,7 @@ class TestGiteaBackendPushFiles: ) client.post = AsyncMock( side_effect=[ - _make_mock_response(201, {}), # create ref + _make_mock_response(201, {}), # create ref _make_mock_response(201, {"sha": "blob1"}), _make_mock_response(201, {"sha": "new-tree"}), _make_mock_response(201, {"sha": "new-commit"}), @@ -212,9 +216,7 @@ class TestGiteaBackendPushFiles: ) client.patch = AsyncMock(return_value=_make_mock_response(200, {})) - result = await self.backend.push_files( - self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client - ) + result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client) assert result["status"] == "success" ref_create_call = client.post.call_args_list[0] @@ -238,14 +240,262 @@ class TestGiteaBackendPushFiles: ] ) - result = await self.backend.push_files( - self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client - ) + result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client) assert result["status"] == "failed" assert result["message"] == f"Failed to create tree: {'x' * 197}..." +class TestGiteaBackendListShapeRefResponse: + """#1224, #1225 regression: Gitea/Forgejo return refs as a *list*, not a dict. + + GitHub: ``GET /git/refs/heads/{branch}`` -> ``{"ref": ..., "object": {...}}``. + Gitea/Forgejo: same endpoint -> ``[{"ref": ..., "object": {...}}]``. + + The pre-fix code did ``response.json()["object"]["sha"]`` on the Gitea path + and crashed with ``list indices must be integers or slices, not str``. + """ + + def setup_method(self): + self.backend = GiteaBackend() + self.repo_url = "https://git.example.com/owner/repo" + self.token = "gitea-token" + self.branch = "bambuddy-backup" + + def test_ref_sha_extracts_from_list(self): + assert self.backend._ref_sha([{"object": {"sha": "abc"}}]) == "abc" + + def test_ref_sha_still_accepts_dict_shape(self): + # Defensive — if Gitea ever returns a dict (older versions, future change), + # we don't want to break. + assert self.backend._ref_sha({"object": {"sha": "abc"}}) == "abc" + + def test_ref_sha_raises_on_empty_list(self): + with pytest.raises(ValueError): + self.backend._ref_sha([]) + + @pytest.mark.asyncio + async def test_push_files_handles_list_shape_branch_ref(self): + """The configured backup branch already exists — ref endpoint returns a list.""" + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(200, [{"object": {"sha": "base-commit"}}]), # list shape + _make_mock_response(200, {"tree": {"sha": "base-tree"}}), + _make_mock_response(200, {"tree": []}), + ] + ) + client.post = AsyncMock( + side_effect=[ + _make_mock_response(201, {"sha": "blob1"}), + _make_mock_response(201, {"sha": "new-tree"}), + _make_mock_response(201, {"sha": "new-commit"}), + ] + ) + client.patch = AsyncMock(return_value=_make_mock_response(200, {})) + + result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client) + + assert result["status"] == "success" + assert result["commit_sha"] == "new-commit" + + @pytest.mark.asyncio + async def test_create_branch_handles_list_shape_default_branch_ref(self): + """Backup branch missing — must read default branch's ref, also list-shaped.""" + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(404, {}), # missing backup branch + _make_mock_response(200, {"default_branch": "main"}), # repo info + _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref (list) + # second push_files() call — branch now exists + _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), + _make_mock_response(200, {"tree": {"sha": "main-tree"}}), + _make_mock_response(200, {"tree": []}), + ] + ) + client.post = AsyncMock( + side_effect=[ + _make_mock_response(201, {}), # create branch ref + _make_mock_response(201, {"sha": "blob1"}), + _make_mock_response(201, {"sha": "new-tree"}), + _make_mock_response(201, {"sha": "new-commit"}), + ] + ) + client.patch = AsyncMock(return_value=_make_mock_response(200, {})) + + result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client) + + assert result["status"] == "success" + + +class TestGiteaBackendEmptyRepoInitialCommit: + """#1224 regression: Git Data API refuses writes against empty Gitea repos. + + GitHub accepts ``POST /git/blobs`` against an empty repo and creates the + initial commit + branch. Gitea returns 404 on every blob/tree/commit POST + until the repo has at least one commit. The fix is to use the Contents + API (``POST /repos/.../contents``) which seeds the branch + initial + commit in a single transaction. + """ + + def setup_method(self): + self.backend = GiteaBackend() + self.repo_url = "https://git.example.com/owner/repo" + self.token = "gitea-token" + self.branch = "main" + + @pytest.mark.asyncio + async def test_empty_repo_uses_contents_api_not_git_data_api(self): + files = {"config/printers.json": {"name": "p1"}, "config/spools.json": {"id": 1}} + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(404, {}), # backup branch missing + _make_mock_response(200, {"default_branch": "main"}), # repo info + _make_mock_response(404, {}), # default branch missing too -> empty repo + ] + ) + client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "initial-sha"}})) + + result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client) + + assert result["status"] == "success" + assert result["files_changed"] == 2 + assert result["commit_sha"] == "initial-sha" + + contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]] + blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]] + tree_calls = [c for c in client.post.call_args_list if "/git/trees" in c.args[0]] + commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]] + ref_calls = [c for c in client.post.call_args_list if "/git/refs" in c.args[0]] + # Exactly one Contents API call, no Git Data API writes + assert len(contents_calls) == 1 + assert len(blob_calls) == 0 + assert len(tree_calls) == 0 + assert len(commit_calls) == 0 + assert len(ref_calls) == 0 + + @pytest.mark.asyncio + async def test_contents_api_payload_shape(self): + """The Contents API call must carry branch+new_branch+files in the documented shape.""" + files = {"a.json": {"k": "v"}, "nested/b.json": {"x": 1}} + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(404, {}), + _make_mock_response(200, {"default_branch": "main"}), + _make_mock_response(404, {}), + ] + ) + client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "abc"}})) + + await self.backend.push_files(self.repo_url, self.token, self.branch, files, client) + + body = client.post.call_args.kwargs["json"] + assert body["branch"] == "main" + assert body["new_branch"] == "main" + assert body["message"].startswith("Initial Bambuddy backup") + assert len(body["files"]) == 2 + paths = {f["path"] for f in body["files"]} + assert paths == {"a.json", "nested/b.json"} + for f in body["files"]: + assert f["operation"] == "create" + # Content is base64-encoded JSON of the original dict + decoded = base64.b64decode(f["content"]).decode("utf-8") + assert json.loads(decoded) == files[f["path"]] + + @pytest.mark.asyncio + async def test_contents_api_failure_truncates_error_body(self): + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(404, {}), + _make_mock_response(200, {"default_branch": "main"}), + _make_mock_response(404, {}), + ] + ) + client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500)) + + result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client) + + assert result["status"] == "failed" + assert result["message"] == f"Failed to create initial commit: {'x' * 197}..." + + @pytest.mark.asyncio + async def test_empty_files_skips_contents_api_call(self): + # Edge: nothing to commit -> don't make a useless Contents API call. + client = AsyncMock() + client.post = AsyncMock() + + result = await self.backend._create_initial_commit( + client, {}, "https://git.example.com/api/v1", "owner", "repo", "main", {} + ) + + assert result["status"] == "skipped" + assert result["files_changed"] == 0 + client.post.assert_not_called() + + +class TestForgejoInheritsGiteaFixes: + """ForgejoBackend extends GiteaBackend with no overrides — must inherit both fixes.""" + + @pytest.mark.asyncio + async def test_forgejo_handles_list_shape_ref_response(self): + backend = ForgejoBackend() + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(200, [{"object": {"sha": "base-commit"}}]), + _make_mock_response(200, {"tree": {"sha": "base-tree"}}), + _make_mock_response(200, {"tree": []}), + ] + ) + client.post = AsyncMock( + side_effect=[ + _make_mock_response(201, {"sha": "blob1"}), + _make_mock_response(201, {"sha": "new-tree"}), + _make_mock_response(201, {"sha": "new-commit"}), + ] + ) + client.patch = AsyncMock(return_value=_make_mock_response(200, {})) + + result = await backend.push_files( + "https://forgejo.example.com/owner/repo", + "token", + "bambuddy-backup", + {"a.json": {"k": "v"}}, + client, + ) + assert result["status"] == "success" + + @pytest.mark.asyncio + async def test_forgejo_empty_repo_uses_contents_api(self): + backend = ForgejoBackend() + client = AsyncMock() + client.get = AsyncMock( + side_effect=[ + _make_mock_response(404, {}), + _make_mock_response(200, {"default_branch": "main"}), + _make_mock_response(404, {}), + ] + ) + client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "fj-sha"}})) + + result = await backend.push_files( + "https://forgejo.example.com/owner/repo", + "token", + "main", + {"a.json": {"k": "v"}}, + client, + ) + assert result["status"] == "success" + contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]] + blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]] + assert len(contents_calls) == 1 + assert len(blob_calls) == 0 + + class TestForgejoBackendApiBase: def setup_method(self): self.backend = ForgejoBackend() @@ -439,18 +689,17 @@ class TestGitLabBackendPushFiles: """Files beyond the first 100 are fetched; a file on page 2 is correctly skipped if unchanged.""" sha = _blob_sha(self.files["config/printers.json"]) page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)] - page2_items = ( - [{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)] - + [{"type": "blob", "path": "config/printers.json", "id": sha}] - ) # 120 total blobs across two pages + page2_items = [{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)] + [ + {"type": "blob", "path": "config/printers.json", "id": sha} + ] # 120 total blobs across two pages client = AsyncMock() client.get = AsyncMock( side_effect=[ _make_mock_response(200, {"name": self.branch}), # branch check - _make_mock_response(200, page1_items), # tree page 1 - _make_mock_response(200, page2_items), # tree page 2 - _make_mock_response(200, []), # tree page 3 empty, stop + _make_mock_response(200, page1_items), # tree page 1 + _make_mock_response(200, page2_items), # tree page 2 + _make_mock_response(200, []), # tree page 3 empty, stop ] )