Przeglądaj źródła

● fix(backup): Gitea wraps GitCommit in Commit schema — extract tree SHA from both shapes (issue #1224 follow-up)

  Subsequent backups against Gitea 1.24+ failed with the opaque
  "Backup failed: 'tree'" message after the initial-backup fix landed in
  7ee89b56. Root cause: Gitea's GET /repos/{owner}/{repo}/git/commits/{sha}
  returns the wrapped Commit schema where the tree lives at
  data["commit"]["tree"]["sha"], whereas GitHub's same-named Git Database
  endpoint returns the unwrapped GitCommit schema with tree at the top
  level. The bare commit_response.json()["tree"]["sha"] lookup at
  gitea.py:109 raised KeyError: 'tree' and the broad except in push_files
  surfaced it as the opaque "Backup failed: 'tree'" string — masking the
  real shape mismatch.

  Adds a _commit_tree_sha() helper that tries the flat shape first
  (GitHub-compatible / older Gitea) and falls back to the wrapped shape
  (Gitea 1.24+, Forgejo). Returns None on truly malformed responses;
  push_files maps that to a clear "Failed to extract tree SHA from commit
  response" instead of leaking a KeyError repr. Keeps the existing-files
  diff working on both shapes so subsequent backups don't re-upload every
  blob — preferred over the .get()-and-skip approach which would have
  required also dropping base_tree from the tree POST and re-uploading
  unchanged files on every backup.
maziggy 2 tygodni temu
rodzic
commit
233808956b

Plik diff jest za duży
+ 0 - 0
CHANGELOG.md


+ 20 - 1
backend/app/services/git_providers/gitea.py

@@ -40,6 +40,23 @@ class GiteaBackend(GitHubBackend):
             return ref_data[0]["object"]["sha"]
         return ref_data["object"]["sha"]
 
+    @staticmethod
+    def _commit_tree_sha(commit_data: dict) -> str | None:
+        """Extract the tree SHA from a commit response.
+
+        GitHub's ``GET /git/commits/{sha}`` returns the GitCommit schema with
+        ``tree`` at the top level. Gitea's same-named endpoint returns the
+        wrapped Commit schema where ``tree`` lives under ``commit``. Try the
+        flat shape first (GitHub-compatible deployments / Gitea ≤ 1.23) then
+        fall back to the wrapped shape (Gitea 1.24+, Forgejo).
+        """
+        tree_node = commit_data.get("tree")
+        if not isinstance(tree_node, dict):
+            tree_node = (commit_data.get("commit") or {}).get("tree")
+        if isinstance(tree_node, dict):
+            return tree_node.get("sha")
+        return None
+
     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:
@@ -106,7 +123,9 @@ class GiteaBackend(GitHubBackend):
             if commit_response.status_code != 200:
                 return {"status": "failed", "message": "Failed to get current commit"}
 
-            current_tree_sha = commit_response.json()["tree"]["sha"]
+            current_tree_sha = self._commit_tree_sha(commit_response.json())
+            if not current_tree_sha:
+                return {"status": "failed", "message": "Failed to extract tree SHA from commit response"}
 
             tree_response = await client.get(
                 f"{api_base}/repos/{owner}/{repo}/git/trees/{current_tree_sha}?recursive=1", headers=headers

+ 73 - 0
backend/tests/unit/test_git_providers.py

@@ -329,6 +329,79 @@ class TestGiteaBackendListShapeRefResponse:
         assert result["status"] == "success"
 
 
+class TestGiteaBackendWrappedCommitResponse:
+    """#1224 regression: Gitea wraps the GitCommit fields under ``commit``.
+
+    GitHub's ``GET /git/commits/{sha}`` returns the unwrapped GitCommit schema
+    (``tree`` at top level). Gitea's same-named endpoint returns the wrapped
+    Commit schema where ``tree`` lives at ``commit.tree`` (Gitea 1.24+).
+
+    Pre-fix code did ``commit_response.json()["tree"]["sha"]`` and raised
+    ``KeyError: 'tree'`` on every backup *after* the initial one — surfaced to
+    the user as the opaque ``Backup failed: 'tree'`` message.
+    """
+
+    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_commit_tree_sha_reads_flat_shape(self):
+        """GitHub-compatible / older Gitea: ``tree`` at top level."""
+        assert self.backend._commit_tree_sha({"tree": {"sha": "abc"}}) == "abc"
+
+    def test_commit_tree_sha_reads_wrapped_shape(self):
+        """Gitea 1.24+ / Forgejo: ``tree`` nested under ``commit``."""
+        assert self.backend._commit_tree_sha({"sha": "c1", "commit": {"tree": {"sha": "abc"}}}) == "abc"
+
+    def test_commit_tree_sha_returns_none_on_missing(self):
+        assert self.backend._commit_tree_sha({"sha": "c1", "commit": {}}) is None
+        assert self.backend._commit_tree_sha({}) is None
+
+    @pytest.mark.asyncio
+    async def test_push_files_handles_wrapped_commit_response(self):
+        """Subsequent backup against Gitea 1.24+ — commit endpoint returns wrapped shape."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
+                # Wrapped Gitea commit response — tree under "commit", not top level
+                _make_mock_response(200, {"sha": "base-commit", "commit": {"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_push_files_fails_cleanly_when_tree_sha_missing(self):
+        """Defensive: malformed/unexpected commit response surfaces a clear error, not KeyError."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
+                _make_mock_response(200, {"sha": "base-commit"}),  # no tree at all
+            ]
+        )
+
+        result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
+
+        assert result["status"] == "failed"
+        assert "tree SHA" in result["message"]
+
+
 class TestGiteaBackendEmptyRepoInitialCommit:
     """#1224 regression: Git Data API refuses writes against empty Gitea repos.
 

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików