Parcourir la source

fix(backup): Gitea/Forgejo handle list-shaped ref response and empty-repo bootstrap (issue #1224 and #1225)

  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.
maziggy il y a 3 semaines
Parent
commit
7ee89b561b
3 fichiers modifiés avec 522 ajouts et 19 suppressions
  1. 0 0
      CHANGELOG.md
  2. 258 4
      backend/app/services/git_providers/gitea.py
  3. 264 15
      backend/tests/unit/test_git_providers.py

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
CHANGELOG.md


+ 258 - 4
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
 import re
+from datetime import datetime, timezone
+
+import httpx
 
 
 from backend.app.services.git_providers.github import GitHubBackend
 from backend.app.services.git_providers.github import GitHubBackend
 
 
+logger = logging.getLogger(__name__)
+
 
 
 class GiteaBackend(GitHubBackend):
 class GiteaBackend(GitHubBackend):
     """Backend for Gitea instances.
     """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]:
     def parse_repo_url(self, url: str) -> tuple[str, str]:
         """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
         """Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
         if not url or len(url) > 500:
         if not url or len(url) > 500:
@@ -42,3 +69,230 @@ class GiteaBackend(GitHubBackend):
         headers = super().get_headers(token)
         headers = super().get_headers(token)
         headers["Accept"] = "application/json"
         headers["Accept"] = "application/json"
         return headers
         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)}

+ 264 - 15
backend/tests/unit/test_git_providers.py

@@ -1,5 +1,6 @@
 """Unit tests for the git_providers abstraction package."""
 """Unit tests for the git_providers abstraction package."""
 
 
+import base64
 import hashlib
 import hashlib
 import json
 import json
 from unittest.mock import AsyncMock, MagicMock
 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"
         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):
     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):
     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"
         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(
         client.post = AsyncMock(
             side_effect=[
             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": "blob1"}),
                 _make_mock_response(201, {"sha": "new-tree"}),
                 _make_mock_response(201, {"sha": "new-tree"}),
                 _make_mock_response(201, {"sha": "new-commit"}),
                 _make_mock_response(201, {"sha": "new-commit"}),
@@ -212,9 +216,7 @@ class TestGiteaBackendPushFiles:
         )
         )
         client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
         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"
         assert result["status"] == "success"
         ref_create_call = client.post.call_args_list[0]
         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["status"] == "failed"
         assert result["message"] == f"Failed to create tree: {'x' * 197}..."
         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:
 class TestForgejoBackendApiBase:
     def setup_method(self):
     def setup_method(self):
         self.backend = ForgejoBackend()
         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."""
         """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"])
         sha = _blob_sha(self.files["config/printers.json"])
         page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)]
         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 = AsyncMock()
         client.get = AsyncMock(
         client.get = AsyncMock(
             side_effect=[
             side_effect=[
                 _make_mock_response(200, {"name": self.branch}),  # branch check
                 _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
             ]
             ]
         )
         )
 
 

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff