Procházet zdrojové kódy

feat(backup): restore selected categories from a Git backup commit (#2656)

The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.

Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.

The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.

Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.

Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.

Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.

Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.

Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.

Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
jmoore-skild před 1 měsícem
rodič
revize
6a239314dc

+ 69 - 1
backend/app/api/routes/github_backup.py

@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
+    REF_PATTERN,
     CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
@@ -19,10 +20,15 @@ from backend.app.schemas.github_backup import (
     GitHubBackupLogResponse,
     GitHubBackupStatus,
     GitHubBackupTriggerResponse,
+    GitHubCommitListResponse,
+    GitHubRestorePreview,
+    GitHubRestoreRequest,
+    GitHubRestoreResponse,
     GitHubTestConnectionResponse,
     ProviderType,
 )
 from backend.app.services.github_backup import github_backup_service
+from backend.app.services.github_restore import github_restore_service
 
 logger = logging.getLogger(__name__)
 
@@ -388,13 +394,75 @@ async def get_status(
         configured=True,
         enabled=config.enabled,
         is_running=github_backup_service.is_running,
-        progress=github_backup_service.progress,
+        restore_running=github_restore_service.is_running,
+        progress=github_backup_service.progress or github_restore_service.progress,
         last_backup_at=config.last_backup_at,
         last_backup_status=config.last_backup_status,
         next_scheduled_run=config.next_scheduled_run,
     )
 
 
+@router.get("/commits", response_model=GitHubCommitListResponse)
+async def list_commits(
+    limit: int = Query(default=20, ge=1, le=100),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """List recent backup commits so the user can pick one to restore from."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    commit_result = await github_restore_service.list_commits(config, limit=limit)
+    return GitHubCommitListResponse(**commit_result)
+
+
+@router.get("/restore/preview", response_model=GitHubRestorePreview)
+async def preview_restore(
+    ref: str = Query(default="HEAD", pattern=REF_PATTERN),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """Report which categories a given backup commit contains."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    preview = await github_restore_service.preview(config, ref=ref)
+    return GitHubRestorePreview(**preview)
+
+
+@router.post("/restore", response_model=GitHubRestoreResponse)
+async def restore_backup(
+    request: GitHubRestoreRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """Restore selected categories from one backup commit.
+
+    Note there is no private-repo gate here, unlike the config endpoints: that
+    check exists to stop credentials leaving the instance, and this path only
+    reads. A config can only be saved against a private repo anyway.
+    """
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    restore_result = await github_restore_service.run_restore(
+        config.id,
+        ref=request.ref,
+        categories=request.categories,
+        overwrite_existing=request.overwrite_existing,
+    )
+    return GitHubRestoreResponse(**restore_result)
+
+
 @router.get("/logs", response_model=list[GitHubBackupLogResponse])
 async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),

+ 102 - 0
backend/app/schemas/github_backup.py

@@ -176,6 +176,7 @@ class GitHubBackupStatus(BaseModel):
     configured: bool = Field(description="Whether backup is configured")
     enabled: bool = Field(description="Whether backup is enabled")
     is_running: bool = Field(description="Whether a backup is currently running")
+    restore_running: bool = Field(default=False, description="Whether a restore is currently running")
     progress: str | None = Field(default=None, description="Current backup progress message")
     last_backup_at: datetime | None
     last_backup_status: str | None
@@ -204,3 +205,104 @@ class GitHubBackupTriggerResponse(BaseModel):
     log_id: int | None = None
     commit_sha: str | None = None
     files_changed: int = 0
+
+
+# --- Restore (issue #2656) --------------------------------------------------
+
+# "HEAD" means "whatever the branch tip is right now"; the service resolves it
+# to a concrete SHA before reading anything so preview and apply can't straddle
+# two different commits. Anything else must look like a git object name.
+REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
+
+
+class RestoreCategory(StrEnum):
+    """Backup categories that can be restored.
+
+    Cloud profiles are deliberately absent: the backup collector never actually
+    writes ``cloud_profiles/*.json`` (it reads a "setting" list key the Bambu
+    Cloud API does not return), and the preset list it would collect carries no
+    setting payload to restore from. Tracked separately from #2656.
+    """
+
+    KPROFILES = "kprofiles"
+    SETTINGS = "settings"
+    SPOOLS = "spools"
+    ARCHIVES = "archives"
+
+
+class GitHubCommitInfo(BaseModel):
+    """One commit in the backup repository."""
+
+    sha: str
+    message: str
+    author: str
+    date: str
+
+
+class GitHubCommitListResponse(BaseModel):
+    """Schema for the commit picker."""
+
+    success: bool
+    message: str
+    branch: str
+    commits: list[GitHubCommitInfo] = Field(default_factory=list)
+
+
+class GitHubRestorePreviewCategory(BaseModel):
+    """What a single category looks like inside one backup commit."""
+
+    category: RestoreCategory
+    available: bool = Field(description="Whether this category is present in the commit")
+    item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
+    detail: str | None = Field(default=None, description="Why unavailable, or extra context")
+
+
+class GitHubRestorePreview(BaseModel):
+    """Schema for inspecting a commit before restoring from it."""
+
+    success: bool
+    message: str
+    ref: str = Field(description="The concrete commit SHA that was inspected")
+    commit: GitHubCommitInfo | None = None
+    metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
+    categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
+
+
+class GitHubRestoreRequest(BaseModel):
+    """Schema for triggering a restore."""
+
+    ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
+    categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
+    overwrite_existing: bool = Field(
+        default=False,
+        description="Update rows that already exist locally. When false, only missing rows are inserted.",
+    )
+
+    @model_validator(mode="after")
+    def deduplicate_categories(self) -> "GitHubRestoreRequest":
+        # Same category twice would double-count the result totals.
+        seen: list[RestoreCategory] = []
+        for category in self.categories:
+            if category not in seen:
+                seen.append(category)
+        self.categories = seen
+        return self
+
+
+class GitHubRestoreCategoryResult(BaseModel):
+    """Per-category outcome of a restore."""
+
+    restored: int = 0
+    skipped: int = 0
+    failed: int = 0
+    notes: list[str] = Field(default_factory=list)
+
+
+class GitHubRestoreResponse(BaseModel):
+    """Schema for the restore result."""
+
+    success: bool
+    message: str
+    log_id: int | None = None
+    ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
+    results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)

+ 58 - 0
backend/app/services/git_providers/base.py

@@ -76,3 +76,61 @@ class GitProviderBackend(ABC):
         client: httpx.AsyncClient,
     ) -> dict:
         """Push files to the repository. Returns status/message/commit_sha/files_changed."""
+
+    # --- Read side (restore, issue #2656) ---------------------------------
+    # The backup path only ever writes. Restore needs to walk history, list a
+    # snapshot and read individual blobs back, so these three mirror the
+    # ``{"success": bool, "message": str, ...}`` convention ``test_connection``
+    # already uses rather than raising.
+
+    @abstractmethod
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch``, newest first.
+
+        Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
+        """
+
+    @abstractmethod
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List every blob path present at ``ref``.
+
+        ``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
+        via :meth:`list_commits` first, so the snapshot being previewed and the
+        one being restored are provably the same commit even if a scheduled
+        backup lands in between.
+
+        Returns ``{"success", "message", "paths": [str]}``.
+        """
+
+    @abstractmethod
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """Read several files' decoded UTF-8 text at ``ref``.
+
+        Batched rather than one-file-at-a-time so providers that need a tree
+        listing to map path -> blob SHA can do that lookup once for the whole
+        restore instead of per file.
+
+        Returns ``{"success", "message", "files": {path: text}}``. Paths absent
+        from the commit are simply missing from ``files`` — that is not an error,
+        since which categories a given backup contains varies by config.
+        """

+ 208 - 0
backend/app/services/git_providers/github.py

@@ -115,6 +115,214 @@ class GitHubBackend(GitProviderBackend):
                 "is_private": None,
             }
 
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch`` via the repo commits API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            # GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo
+            # do the reverse. Sending both lets GiteaBackend inherit this method
+            # unchanged instead of duplicating it for one query parameter.
+            response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/commits",
+                headers=headers,
+                params={"sha": branch, "per_page": limit, "limit": limit},
+            )
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": (
+                        f"Branch '{branch}' not found, or the repository has no commits yet. "
+                        "Run a backup before restoring."
+                    ),
+                    "commits": [],
+                }
+            if response.status_code != 200:
+                msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("list_commits %s/%s: %s", owner, repo, msg)
+                return {"success": False, "message": msg, "commits": []}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
+            if not isinstance(data, list):
+                return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
+
+            return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)}
+
+        except Exception as e:
+            logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
+
+    @staticmethod
+    def _parse_commit_entries(data: list, limit: int) -> list[dict]:
+        """Normalise GitHub/Gitea commit list entries to our flat shape."""
+        commits = []
+        for entry in data[:limit]:
+            if not isinstance(entry, dict):
+                continue
+            sha = entry.get("sha")
+            if not isinstance(sha, str) or not sha:
+                continue
+            commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {}
+            author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
+            commits.append(
+                {
+                    "sha": sha,
+                    "message": commit.get("message") or "",
+                    "author": author.get("name") or "",
+                    "date": author.get("date") or "",
+                }
+            )
+        return commits
+
+    async def _blob_shas_at(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        ref: str,
+    ) -> tuple[dict[str, str] | None, str]:
+        """Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``.
+
+        A commit SHA is a valid tree-ish for the trees API, so this resolves the
+        commit's tree in one request rather than commit -> tree -> list.
+        """
+        response = await client.get(
+            f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1",
+            headers=headers,
+        )
+        if response.status_code == 404:
+            return None, f"Commit or tree '{ref}' not found in the repository"
+        if response.status_code != 200:
+            return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+        try:
+            data = response.json()
+        except ValueError:
+            return None, "Non-JSON response listing tree"
+        # Same limit the push path guards against: a truncated listing would make
+        # a restore silently skip categories that are actually in the backup.
+        if data.get("truncated"):
+            return None, (
+                "Repository tree exceeds the API listing limit (truncated=true), so the backup "
+                "contents cannot be enumerated reliably. Rotate the backup repository."
+            )
+        blobs: dict[str, str] = {}
+        for item in data.get("tree", []):
+            if not isinstance(item, dict) or item.get("type") != "blob":
+                continue
+            path, sha = item.get("path"), item.get("sha")
+            if isinstance(path, str) and isinstance(sha, str) and path and sha:
+                blobs[path] = sha
+        return blobs, ""
+
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List blob paths present at ``ref`` via the Git Data trees API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
+            if blobs is None:
+                logger.warning("list_tree %s/%s ref=%s: %s", owner, repo, ref, error)
+                return {"success": False, "message": error, "paths": []}
+
+            return {"success": True, "message": "OK", "paths": sorted(blobs)}
+
+        except Exception as e:
+            logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": []}
+
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """Read ``paths`` at ``ref`` via the Git Data blobs API.
+
+        The blobs API is used rather than the contents API because contents
+        inlines only files up to 1 MB — an archive-heavy ``print_history.json``
+        can exceed that, and it would come back with an empty body instead of an
+        error.
+        """
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
+            if blobs is None:
+                logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error)
+                return {"success": False, "message": error, "files": {}}
+
+            files: dict[str, str] = {}
+            for path in paths:
+                sha = blobs.get(path)
+                if sha is None:
+                    continue
+                response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers)
+                if response.status_code != 200:
+                    msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                    logger.warning("fetch_files %s/%s: %s", owner, repo, msg)
+                    return {"success": False, "message": msg, "files": {}}
+                text, error = self._decode_blob(response, path)
+                if text is None:
+                    logger.warning("fetch_files %s/%s: %s", owner, repo, error)
+                    return {"success": False, "message": error, "files": {}}
+                files[path] = text
+
+            return {"success": True, "message": "OK", "files": files}
+
+        except Exception as e:
+            logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
+
+    def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]:
+        """Decode a blob API response body to text, or return an error message."""
+        try:
+            data = response.json()
+        except ValueError:
+            return None, f"Non-JSON response reading {path}"
+        if not isinstance(data, dict):
+            return None, f"Unexpected shape reading {path}"
+        content = data.get("content")
+        if not isinstance(content, str):
+            return None, f"Missing content reading {path}"
+        encoding = data.get("encoding", "base64")
+        try:
+            if encoding == "base64":
+                # Both providers wrap base64 payloads at 60 chars; b64decode
+                # tolerates the newlines, but be explicit about it.
+                return base64.b64decode(content).decode("utf-8"), ""
+            if encoding in ("utf-8", "text", "plain"):
+                return content, ""
+        except (ValueError, UnicodeDecodeError) as e:
+            return None, f"Could not decode {path}: {type(e).__name__}"
+        return None, f"Unsupported blob encoding {encoding!r} reading {path}"
+
     async def push_files(
         self,
         repo_url: str,

+ 195 - 0
backend/app/services/git_providers/gitlab.py

@@ -115,6 +115,201 @@ class GitLabBackend(GitProviderBackend):
                 "is_private": None,
             }
 
+    def _encoded_project(self, repo_url: str) -> str:
+        """Return the URL-encoded ``namespace/project`` path for /api/v4/projects/."""
+        owner, repo = self.parse_repo_url(repo_url)
+        return urllib.parse.quote(f"{owner}/{repo}", safe="")
+
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch`` via /repository/commits."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            response = await client.get(
+                f"{api_base}/projects/{encoded_path}/repository/commits",
+                headers=headers,
+                params={"ref_name": branch, "per_page": limit},
+            )
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": (
+                        f"Branch '{branch}' not found, or the repository has no commits yet. "
+                        "Run a backup before restoring."
+                    ),
+                    "commits": [],
+                }
+            if response.status_code != 200:
+                msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("list_commits %s: %s", repo_url, msg)
+                return {"success": False, "message": msg, "commits": []}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
+            if not isinstance(data, list):
+                return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
+
+            commits = []
+            for entry in data[:limit]:
+                if not isinstance(entry, dict):
+                    continue
+                sha = entry.get("id")
+                if not isinstance(sha, str) or not sha:
+                    continue
+                # GitLab flattens author/date onto the commit itself rather than
+                # nesting them under "commit" the way GitHub does.
+                commits.append(
+                    {
+                        "sha": sha,
+                        "message": entry.get("message") or "",
+                        "author": entry.get("author_name") or "",
+                        "date": entry.get("committed_date") or entry.get("created_at") or "",
+                    }
+                )
+
+            return {"success": True, "message": "OK", "commits": commits}
+
+        except Exception as e:
+            logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
+
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List blob paths at ``ref`` via /repository/tree, following pagination."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            paths: list[str] = []
+            page = 1
+            # GitLab's tree endpoint paginates instead of exposing a "truncated"
+            # flag, so walk pages until one comes back short. The page cap stops
+            # a malformed X-Next-Page loop from spinning forever.
+            while page <= 50:
+                response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/tree",
+                    headers=headers,
+                    params={"ref": ref, "recursive": "true", "per_page": 100, "page": page},
+                )
+                if response.status_code == 404:
+                    return {
+                        "success": False,
+                        "message": f"Commit or tree '{ref}' not found in the repository",
+                        "paths": [],
+                    }
+                if response.status_code != 200:
+                    msg = (
+                        f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                    )
+                    logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
+                    return {"success": False, "message": msg, "paths": []}
+
+                try:
+                    data = response.json()
+                except ValueError:
+                    return {"success": False, "message": "Non-JSON response listing tree", "paths": []}
+                if not isinstance(data, list):
+                    return {"success": False, "message": "Unexpected shape listing tree", "paths": []}
+
+                for item in data:
+                    if isinstance(item, dict) and item.get("type") == "blob":
+                        path = item.get("path")
+                        if isinstance(path, str) and path:
+                            paths.append(path)
+
+                if len(data) < 100:
+                    break
+                page += 1
+
+            return {"success": True, "message": "OK", "paths": sorted(paths)}
+
+        except Exception as e:
+            logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": []}
+
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """Read ``paths`` at ``ref`` via /repository/files/{path}."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            files: dict[str, str] = {}
+            for path in paths:
+                encoded_file = urllib.parse.quote(path, safe="")
+                response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}",
+                    headers=headers,
+                    params={"ref": ref},
+                )
+                # A path absent from this commit is expected — which categories a
+                # backup contains varies by config — so skip rather than fail.
+                if response.status_code == 404:
+                    continue
+                if response.status_code != 200:
+                    msg = (
+                        f"Failed to read {path} (HTTP {response.status_code}): "
+                        f"{self._truncated_response_text(response)}"
+                    )
+                    logger.warning("fetch_files %s: %s", repo_url, msg)
+                    return {"success": False, "message": msg, "files": {}}
+
+                try:
+                    data = response.json()
+                except ValueError:
+                    return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}}
+                if not isinstance(data, dict):
+                    return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}}
+
+                content = data.get("content")
+                if not isinstance(content, str):
+                    return {"success": False, "message": f"Missing content reading {path}", "files": {}}
+                encoding = data.get("encoding", "base64")
+                try:
+                    if encoding == "base64":
+                        files[path] = base64.b64decode(content).decode("utf-8")
+                    elif encoding in ("text", "utf-8", "plain"):
+                        files[path] = content
+                    else:
+                        return {
+                            "success": False,
+                            "message": f"Unsupported encoding {encoding!r} reading {path}",
+                            "files": {},
+                        }
+                except (ValueError, UnicodeDecodeError) as e:
+                    return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}}
+
+            return {"success": True, "message": "OK", "files": files}
+
+        except Exception as e:
+            logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
+
     async def push_files(
         self,
         repo_url: str,

+ 29 - 0
backend/app/services/github_backup.py

@@ -173,9 +173,32 @@ class GitHubBackupService:
         Returns:
             dict with success, message, log_id, commit_sha, files_changed
         """
+        # Everything from here to `self._running_backup = True` must stay
+        # await-free. Both flags are plain bools and both callers are coroutines
+        # on one event loop, so with no suspension point in between the loop
+        # cannot run the restore service's mirror-image region (see
+        # github_restore.run_restore) in the gap — whichever gets here first sets
+        # its flag before the other can read it. Adding an `await` inside this
+        # block reintroduces the check-then-set race and lets a backup and a
+        # restore run at once.
         if self._running_backup:
             return {"success": False, "message": "A backup is already running", "log_id": None}
 
+        # Imported locally to avoid a module-level import cycle — the restore
+        # service imports this module's singleton to take the mirror-image lock.
+        # A restore rewrites the same tables this collector reads and publishes
+        # K-profiles to the same printers, so the two must not interleave.
+        # (A local `import` of an already-loaded module is not a suspension
+        # point, so it does not break the await-free rule above.)
+        from backend.app.services.github_restore import github_restore_service
+
+        if github_restore_service.is_running:
+            return {
+                "success": False,
+                "message": "A restore is currently running. Wait for it to finish before backing up.",
+                "log_id": None,
+            }
+
         self._running_backup = True
         log_id = None
 
@@ -840,6 +863,12 @@ class GitHubBackupService:
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
                 "created_at": str(a.created_at) if a.created_at else None,
+                # Soft-deleted archives are collected too — their row is kept on
+                # purpose so the stats endpoint keeps counting their filament and
+                # energy (see archive_service.soft_delete_archive). Recording
+                # deleted_at is what lets a restore put them back the way they
+                # were instead of resurrecting them as visible archives.
+                "deleted_at": str(a.deleted_at) if a.deleted_at else None,
             }
             archive_list.append(archive_data)
 

+ 923 - 0
backend/app/services/github_restore.py

@@ -0,0 +1,923 @@
+"""Restore Bambuddy data from a Git provider backup (issue #2656).
+
+The backup side (``github_backup.py``) is push-only: it collects a handful of
+JSON documents and commits them. This module is the read side — it walks the
+backup repository's history, lets a caller inspect what a given commit contains,
+and applies selected categories back into the local database (or, for
+K-profiles, back onto the printers).
+
+Design notes worth knowing before editing:
+
+* **A restore never reuses the backup's primary keys.** ``spool.id`` and
+  ``print_archives.id`` are bare autoincrement columns, so the ids in a backup
+  taken weeks ago very likely belong to unrelated rows today. Rows are matched
+  on natural keys instead, inserted without an explicit id, and an
+  ``old_id -> new_id`` map is threaded through so foreign keys in dependent
+  tables (spool usage history) still line up.
+* **Categories are applied archives -> spools -> settings -> kprofiles.**
+  Archives first because spool usage history references ``archive_id``;
+  K-profiles last because they leave the database and talk to hardware.
+* **Cloud profiles are not restorable.** The backup collector never actually
+  writes ``cloud_profiles/*.json``, and the preset list it would write carries
+  no setting payload. Tracked separately from #2656.
+"""
+
+import asyncio
+import json
+import logging
+import re
+from datetime import datetime, timezone
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
+from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.printer import Printer
+from backend.app.models.project import Project
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.schemas.github_backup import RestoreCategory
+from backend.app.services.git_providers.factory import get_provider_backend
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+METADATA_PATH = "backup_metadata.json"
+SETTINGS_PATH = "settings/app_settings.json"
+SPOOLS_PATH = "spools/inventory.json"
+SPOOL_USAGE_PATH = "spools/usage_history.json"
+ARCHIVES_PATH = "archives/print_history.json"
+
+# kprofiles/{printer_serial}/{nozzle_diameter}.json
+_KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
+
+# Settings keys the backup collector already refuses to write. Applied again on
+# the read side because a backup taken before that denylist existed can still
+# contain them, and a restore must not resurrect a stale credential.
+_SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
+
+# Belt-and-braces for the same reason: any key that looks like a secret is
+# skipped even if it isn't in the explicit denylist above.
+_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
+
+# Nozzle diameters the backup collector iterates. A path outside this set means
+# the backup was written by a newer version, so accept it rather than dropping
+# data, but keep the list for validation messages.
+_KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
+
+
+def _parse_dt(value) -> datetime | None:
+    """Best-effort parse of a datetime the backup wrote via ``str(...)``."""
+    if not value or not isinstance(value, str):
+        return None
+    try:
+        return datetime.fromisoformat(value)
+    except ValueError:
+        return None
+
+
+def _is_blocked_setting_key(key: str) -> bool:
+    lowered = key.lower()
+    return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
+
+
+class _CategoryTally:
+    """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
+
+    def __init__(self) -> None:
+        self.restored = 0
+        self.skipped = 0
+        self.failed = 0
+        self.notes: list[str] = []
+
+    def note(self, message: str) -> None:
+        # Notes are surfaced verbatim in the UI, so keep the list bounded rather
+        # than emitting one line per row for a large backup.
+        if message not in self.notes and len(self.notes) < 20:
+            self.notes.append(message)
+
+    def as_dict(self) -> dict:
+        return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
+
+
+class GitHubRestoreService:
+    """Reads a backup repository and applies selected categories locally."""
+
+    def __init__(self) -> None:
+        self._running_restore: bool = False
+        self._progress: str | None = None
+        self._http_client: httpx.AsyncClient | None = None
+        # Guards the check-then-set on ``_running_restore``. Without it two
+        # concurrent POSTs can both observe False before either sets it.
+        self._lock = asyncio.Lock()
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        if self._http_client is None or self._http_client.is_closed:
+            self._http_client = httpx.AsyncClient(timeout=60.0)
+        return self._http_client
+
+    @property
+    def is_running(self) -> bool:
+        return self._running_restore
+
+    @property
+    def progress(self) -> str | None:
+        return self._progress
+
+    # --- Repository reads --------------------------------------------------
+
+    async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
+        """List recent commits on the configured branch."""
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+        result = await backend.list_commits(
+            repo_url=config.repository_url,
+            token=config.access_token,
+            branch=config.branch,
+            client=client,
+            limit=limit,
+        )
+        result["branch"] = config.branch
+        return result
+
+    async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str]:
+        """Turn ``HEAD`` into a concrete commit SHA.
+
+        Done once up front so a preview and the restore that follows it act on
+        the same commit even if a scheduled backup lands in between.
+        """
+        if ref and ref.upper() != "HEAD":
+            return ref, ""
+        result = await self.list_commits(config, limit=1)
+        if not result.get("success"):
+            return None, result.get("message") or "Could not read the backup repository"
+        commits = result.get("commits") or []
+        if not commits:
+            return None, f"Branch '{config.branch}' has no commits to restore from"
+        return commits[0]["sha"], ""
+
+    def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
+        """Return the paths in ``available`` that belong to ``category``."""
+        if category == RestoreCategory.SETTINGS:
+            return [p for p in (SETTINGS_PATH,) if p in available]
+        if category == RestoreCategory.SPOOLS:
+            return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
+        if category == RestoreCategory.ARCHIVES:
+            return [p for p in (ARCHIVES_PATH,) if p in available]
+        if category == RestoreCategory.KPROFILES:
+            return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
+        return []
+
+    @staticmethod
+    def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
+        """Parse each fetched file, collecting paths that failed to parse."""
+        parsed: dict[str, object] = {}
+        bad: list[str] = []
+        for path, text in raw.items():
+            try:
+                parsed[path] = json.loads(text)
+            except (ValueError, TypeError):
+                bad.append(path)
+        return parsed, bad
+
+    async def preview(self, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
+        """Report which categories a commit contains, and how much is in each."""
+        resolved, error = await self._resolve_ref(config, ref)
+        if resolved is None:
+            return {"success": False, "message": error, "ref": ref, "categories": []}
+
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+
+        tree = await backend.list_tree(
+            repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
+        )
+        if not tree.get("success"):
+            return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
+        available: list[str] = tree.get("paths") or []
+
+        # One batched read covers metadata plus every category payload.
+        wanted = [METADATA_PATH] if METADATA_PATH in available else []
+        for category in RestoreCategory:
+            wanted.extend(self._category_paths(category, available))
+
+        fetched = await backend.fetch_files(
+            repo_url=config.repository_url, token=config.access_token, ref=resolved, paths=wanted, client=client
+        )
+        if not fetched.get("success"):
+            return {
+                "success": False,
+                "message": fetched.get("message") or "Could not read the commit contents",
+                "ref": resolved,
+            }
+        parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
+
+        metadata = parsed.get(METADATA_PATH)
+        metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
+
+        categories = []
+        for category in RestoreCategory:
+            paths = self._category_paths(category, available)
+            if not paths:
+                categories.append(
+                    {
+                        "category": category,
+                        "available": False,
+                        "item_count": 0,
+                        "detail": "Not present in this backup commit",
+                    }
+                )
+                continue
+            unreadable = [p for p in paths if p in bad_paths]
+            if unreadable:
+                categories.append(
+                    {
+                        "category": category,
+                        "available": False,
+                        "item_count": 0,
+                        "detail": f"Unreadable JSON: {', '.join(unreadable)}",
+                    }
+                )
+                continue
+            count, detail = self._count_items(category, parsed)
+            categories.append({"category": category, "available": True, "item_count": count, "detail": detail})
+
+        commit_info = None
+        commits = (await self.list_commits(config, limit=20)).get("commits") or []
+        for entry in commits:
+            if entry["sha"] == resolved:
+                commit_info = entry
+                break
+
+        return {
+            "success": True,
+            "message": "OK",
+            "ref": resolved,
+            "commit": commit_info,
+            "metadata_version": metadata_version,
+            "categories": categories,
+        }
+
+    @staticmethod
+    def _count_items(category: RestoreCategory, parsed: dict) -> tuple[int, str | None]:
+        """Count restorable items for ``category`` and describe any caveat."""
+        if category == RestoreCategory.SETTINGS:
+            payload = parsed.get(SETTINGS_PATH)
+            values = payload.get("settings") if isinstance(payload, dict) else None
+            if not isinstance(values, dict):
+                return 0, "No settings in payload"
+            blocked = sum(1 for key in values if _is_blocked_setting_key(key))
+            detail = f"{blocked} credential-like keys will be skipped" if blocked else None
+            return len(values) - blocked, detail
+
+        if category == RestoreCategory.SPOOLS:
+            payload = parsed.get(SPOOLS_PATH)
+            spools = payload.get("spools") if isinstance(payload, dict) else None
+            usage_payload = parsed.get(SPOOL_USAGE_PATH)
+            usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+            count = len(spools) if isinstance(spools, list) else 0
+            detail = f"plus {len(usage)} usage records" if isinstance(usage, list) and usage else None
+            return count, detail
+
+        if category == RestoreCategory.ARCHIVES:
+            payload = parsed.get(ARCHIVES_PATH)
+            archives = payload.get("archives") if isinstance(payload, dict) else None
+            count = len(archives) if isinstance(archives, list) else 0
+            return count, "Metadata only — 3MF files and thumbnails are not in a Git backup"
+
+        if category == RestoreCategory.KPROFILES:
+            total = 0
+            serials = set()
+            for path, payload in parsed.items():
+                match = _KPROFILE_PATH_RE.match(path)
+                if not match or not isinstance(payload, dict):
+                    continue
+                serials.add(match.group(1))
+                profiles = payload.get("profiles")
+                if isinstance(profiles, list):
+                    total += len(profiles)
+            detail = f"across {len(serials)} printer(s)" if serials else None
+            return total, detail
+
+        return 0, None
+
+    # --- Restore -----------------------------------------------------------
+
+    async def run_restore(
+        self,
+        config_id: int,
+        ref: str,
+        categories: list[RestoreCategory],
+        overwrite_existing: bool = False,
+    ) -> dict:
+        """Apply selected categories from one backup commit."""
+        # Import locally to avoid a module-level cycle: the backup service takes
+        # the mirror-image lock against us.
+        from backend.app.services.github_backup import github_backup_service
+
+        # The lock serialises two concurrent restores; the backup side has no
+        # lock of its own, and relies on this region staying await-free after the
+        # acquisition. Both flags are plain bools on one event loop, so with no
+        # suspension point between the two reads and the write, the loop cannot
+        # slip github_backup.run_backup's mirror-image check in between. Adding an
+        # `await` below the acquisition and above `self._running_restore = True`
+        # would let a backup and a restore run at once.
+        async with self._lock:
+            if self._running_restore:
+                return {"success": False, "message": "A restore is already running", "results": {}}
+            if github_backup_service.is_running:
+                return {
+                    "success": False,
+                    "message": "A backup is currently running. Wait for it to finish before restoring.",
+                    "results": {},
+                }
+            self._running_restore = True
+
+        log_id = None
+        try:
+            async with async_session() as db:
+                result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
+                config = result.scalar_one_or_none()
+                if not config:
+                    return {"success": False, "message": "Configuration not found", "results": {}}
+
+                self._progress = "Resolving commit..."
+                resolved, error = await self._resolve_ref(config, ref)
+                if resolved is None:
+                    return {"success": False, "message": error, "results": {}}
+
+                log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
+                db.add(log)
+                await db.commit()
+                await db.refresh(log)
+                log_id = log.id
+
+                try:
+                    payload, error = await self._read_categories(config, resolved, categories)
+                    if error:
+                        raise RuntimeError(error)
+
+                    results = await self._apply(db, payload, categories, overwrite_existing)
+                    await db.commit()
+
+                    total_restored = sum(tally.restored for tally in results.values())
+                    any_failed = any(tally.failed for tally in results.values())
+
+                    log.status = "failed" if any_failed and total_restored == 0 else "success"
+                    log.completed_at = datetime.now(timezone.utc)
+                    log.files_changed = total_restored
+                    if any_failed:
+                        log.error_message = "Some items could not be restored — see the restore result for detail"
+                    await db.commit()
+
+                    return {
+                        "success": True,
+                        "message": f"Restored {total_restored} item(s) from {resolved[:7]}",
+                        "log_id": log_id,
+                        "ref": resolved,
+                        "results": {name: tally.as_dict() for name, tally in results.items()},
+                    }
+
+                except Exception as e:
+                    logger.exception("Restore failed for config %s ref %s", config_id, resolved)
+                    await db.rollback()
+                    log.status = "failed"
+                    log.completed_at = datetime.now(timezone.utc)
+                    log.error_message = str(e)[:1000]
+                    await db.commit()
+                    return {"success": False, "message": str(e), "log_id": log_id, "ref": resolved, "results": {}}
+
+        finally:
+            self._running_restore = False
+            self._progress = None
+
+    async def _read_categories(
+        self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
+    ) -> tuple[dict, str]:
+        """Fetch and parse just the files the requested categories need."""
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+
+        self._progress = "Listing backup contents..."
+        tree = await backend.list_tree(
+            repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
+        )
+        if not tree.get("success"):
+            return {}, tree.get("message") or "Could not list the commit"
+        available: list[str] = tree.get("paths") or []
+
+        wanted: list[str] = []
+        for category in categories:
+            wanted.extend(self._category_paths(category, available))
+        if not wanted:
+            return {}, "None of the selected categories are present in that commit"
+
+        self._progress = "Downloading backup files..."
+        fetched = await backend.fetch_files(
+            repo_url=config.repository_url, token=config.access_token, ref=ref, paths=wanted, client=client
+        )
+        if not fetched.get("success"):
+            return {}, fetched.get("message") or "Could not read the commit contents"
+
+        parsed, bad = self._parse_json_files(fetched.get("files") or {})
+        if bad:
+            return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
+        return parsed, ""
+
+    async def _apply(
+        self,
+        db: AsyncSession,
+        payload: dict,
+        categories: list[RestoreCategory],
+        overwrite: bool,
+    ) -> dict[str, _CategoryTally]:
+        """Apply categories in dependency order and return per-category tallies."""
+        results: dict[str, _CategoryTally] = {}
+        archive_id_map: dict[int, int] = {}
+
+        # Archives first: spool usage history references archive_id.
+        if RestoreCategory.ARCHIVES in categories:
+            self._progress = "Restoring print archives..."
+            tally = _CategoryTally()
+            await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
+            results[RestoreCategory.ARCHIVES.value] = tally
+
+        if RestoreCategory.SPOOLS in categories:
+            self._progress = "Restoring spool inventory..."
+            tally = _CategoryTally()
+            await self._restore_spools(
+                db,
+                payload.get(SPOOLS_PATH),
+                payload.get(SPOOL_USAGE_PATH),
+                overwrite,
+                tally,
+                archive_id_map,
+            )
+            results[RestoreCategory.SPOOLS.value] = tally
+
+        if RestoreCategory.SETTINGS in categories:
+            self._progress = "Restoring app settings..."
+            tally = _CategoryTally()
+            await self._restore_settings(db, payload.get(SETTINGS_PATH), overwrite, tally)
+            results[RestoreCategory.SETTINGS.value] = tally
+
+        # Last, because it leaves the database and publishes over MQTT.
+        if RestoreCategory.KPROFILES in categories:
+            self._progress = "Sending K-profiles to printers..."
+            tally = _CategoryTally()
+            await self._restore_kprofiles(db, payload, tally)
+            results[RestoreCategory.KPROFILES.value] = tally
+
+        return results
+
+    # --- Per-category appliers --------------------------------------------
+
+    async def _restore_archives(
+        self,
+        db: AsyncSession,
+        payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        id_map: dict[int, int],
+    ) -> None:
+        archives = payload.get("archives") if isinstance(payload, dict) else None
+        if not isinstance(archives, list):
+            tally.note("No archive data in this backup")
+            return
+
+        valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
+        valid_projects = set((await db.execute(select(Project.id))).scalars().all())
+
+        # Only metadata is backed up, never the 3MF/thumbnail bytes, and
+        # print_archives.file_path is NOT NULL — so inserted rows get an empty
+        # path and are history-only. Say so once rather than per row.
+        warned_files = False
+
+        for entry in archives:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
+            started_at = _parse_dt(entry.get("started_at"))
+            existing = await self._find_archive(db, entry, started_at)
+
+            fields = {
+                "print_name": entry.get("print_name"),
+                "print_time_seconds": entry.get("print_time_seconds"),
+                "filament_used_grams": entry.get("filament_used_grams"),
+                "filament_type": entry.get("filament_type"),
+                "filament_color": entry.get("filament_color"),
+                "layer_height": entry.get("layer_height"),
+                "total_layers": entry.get("total_layers"),
+                "nozzle_diameter": entry.get("nozzle_diameter"),
+                "bed_temperature": entry.get("bed_temperature"),
+                "nozzle_temperature": entry.get("nozzle_temperature"),
+                "sliced_for_model": entry.get("sliced_for_model"),
+                "status": entry.get("status") or "completed",
+                "started_at": started_at,
+                "completed_at": _parse_dt(entry.get("completed_at")),
+                "makerworld_url": entry.get("makerworld_url"),
+                "designer": entry.get("designer"),
+                "external_url": entry.get("external_url"),
+                "is_favorite": bool(entry.get("is_favorite")),
+                "tags": entry.get("tags"),
+                "notes": entry.get("notes"),
+                "cost": entry.get("cost"),
+                "failure_reason": entry.get("failure_reason"),
+                "quantity": entry.get("quantity") or 1,
+                "energy_kwh": entry.get("energy_kwh"),
+                "energy_cost": entry.get("energy_cost"),
+                # A soft-deleted archive is still in the backup (its row is kept
+                # so stats keep counting it), so carry the flag across or the
+                # restore turns something the user deleted back into a visible
+                # archive. Backups written before this key existed have no
+                # deleted_at, and those rows can only come back live.
+                "deleted_at": _parse_dt(entry.get("deleted_at")),
+            }
+
+            printer_id = entry.get("printer_id")
+            if printer_id is not None and printer_id not in valid_printers:
+                tally.note("Some archives referenced printers that no longer exist — link cleared")
+                printer_id = None
+            project_id = entry.get("project_id")
+            if project_id is not None and project_id not in valid_projects:
+                tally.note("Some archives referenced projects that no longer exist — link cleared")
+                project_id = None
+            fields["printer_id"] = printer_id
+            fields["project_id"] = project_id
+
+            if existing is not None:
+                if old_id is not None:
+                    id_map[old_id] = existing.id
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                # Overwrite means "make the local row match the backup", which
+                # includes un-deleting one the user deleted after the backup was
+                # taken. Legitimate, but not obvious from a restored/skipped
+                # count, so say it.
+                if existing.deleted_at is not None and fields["deleted_at"] is None:
+                    tally.note("Archive(s) deleted since the backup are visible again — overwrite was on")
+                for key, value in fields.items():
+                    setattr(existing, key, value)
+                tally.restored += 1
+                continue
+
+            if not warned_files:
+                tally.note(
+                    "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup"
+                )
+                warned_files = True
+
+            row = PrintArchive(
+                filename=entry.get("filename") or "restored-from-backup",
+                file_path="",
+                file_size=entry.get("file_size") or 0,
+                content_hash=entry.get("content_hash"),
+                **fields,
+            )
+            created_at = _parse_dt(entry.get("created_at"))
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            await db.flush()
+            if old_id is not None:
+                id_map[old_id] = row.id
+            tally.restored += 1
+
+    async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
+        """Match a backed-up archive to a local row by natural key.
+
+        ``started_at`` is nullable and genuinely NULL for a whole class of rows —
+        the re-slice path in ``library.py`` constructs ``PrintArchive`` without
+        one — so it cannot be *required* by the key. It narrows the match instead:
+        a backed-up row with no ``started_at`` matches a local row that has none
+        either. Requiring it meant those archives never matched, so each restore
+        re-inserted them as duplicates and overwrite mode could never update them.
+
+        ``content_hash`` identifies the sliced file on its own, which is why it is
+        the branch allowed to run without a ``started_at``; ``filename`` is too
+        weak for that (re-slices share it) and still requires one. Two backed-up
+        rows sharing a hash *and* having no ``started_at`` are indistinguishable
+        in the backup, so they collapse onto one local row — better than
+        duplicating both on every restore.
+
+        Soft-deleted rows are matched deliberately: there is no ``deleted_at``
+        filter here because the row still exists, and matching it is what stops a
+        restore inserting a live duplicate of an archive the user has deleted.
+        """
+        started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
+
+        content_hash = entry.get("content_hash")
+        if content_hash:
+            result = await db.execute(
+                select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
+            )
+            row = result.scalars().first()
+            if row is not None:
+                return row
+
+        filename = entry.get("filename")
+        if filename and started_at:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
+            return result.scalars().first()
+        return None
+
+    async def _restore_spools(
+        self,
+        db: AsyncSession,
+        inventory,
+        usage_payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        archive_id_map: dict[int, int],
+    ) -> None:
+        spools = inventory.get("spools") if isinstance(inventory, dict) else None
+        if not isinstance(spools, list):
+            tally.note("No spool data in this backup")
+            return
+
+        spool_id_map: dict[int, int] = {}
+
+        for entry in spools:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
+            existing = await self._find_spool(db, entry)
+
+            fields = {
+                "material": entry.get("material") or "PLA",
+                "subtype": entry.get("subtype"),
+                "color_name": entry.get("color_name"),
+                "rgba": entry.get("rgba"),
+                "brand": entry.get("brand"),
+                "label_weight": entry.get("label_weight") or 1000,
+                "core_weight": entry.get("core_weight") or 250,
+                "weight_used": entry.get("weight_used") or 0,
+                "weight_locked": bool(entry.get("weight_locked")),
+                "slicer_filament": entry.get("slicer_filament"),
+                "slicer_filament_name": entry.get("slicer_filament_name"),
+                "nozzle_temp_min": entry.get("nozzle_temp_min"),
+                "nozzle_temp_max": entry.get("nozzle_temp_max"),
+                "note": entry.get("note"),
+                "cost_per_kg": entry.get("cost_per_kg"),
+                "tag_uid": entry.get("tag_uid"),
+                "tray_uuid": entry.get("tray_uuid"),
+                "data_origin": entry.get("data_origin"),
+                "tag_type": entry.get("tag_type"),
+                "archived_at": _parse_dt(entry.get("archived_at")),
+            }
+
+            if existing is not None:
+                if old_id is not None:
+                    spool_id_map[old_id] = existing.id
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                for key, value in fields.items():
+                    setattr(existing, key, value)
+                tally.restored += 1
+                continue
+
+            row = Spool(**fields)
+            # Carry the original created_at across. Without it the row would be
+            # stamped "now", and the composite fallback in _find_spool (which
+            # keys on created_at) would miss on a second restore and insert a
+            # duplicate instead of matching.
+            created_at = _parse_dt(entry.get("created_at"))
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            await db.flush()
+            if old_id is not None:
+                spool_id_map[old_id] = row.id
+            tally.restored += 1
+
+        await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
+
+    async def _find_spool(self, db: AsyncSession, entry: dict) -> Spool | None:
+        """Match a backed-up spool to a local row.
+
+        Physical identity first (an RFID/Bambu tag is the spool), then a
+        descriptive composite including ``created_at`` so two otherwise
+        identical spools added at different times stay distinct.
+        """
+        tag_uid = entry.get("tag_uid")
+        if tag_uid:
+            result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
+            row = result.scalars().first()
+            if row is not None:
+                return row
+
+        tray_uuid = entry.get("tray_uuid")
+        if tray_uuid:
+            result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
+            row = result.scalars().first()
+            if row is not None:
+                return row
+
+        created_at = _parse_dt(entry.get("created_at"))
+        if created_at is None:
+            return None
+        result = await db.execute(
+            select(Spool).where(
+                Spool.created_at == created_at,
+                Spool.material == (entry.get("material") or "PLA"),
+                Spool.brand == entry.get("brand"),
+                Spool.subtype == entry.get("subtype"),
+                Spool.color_name == entry.get("color_name"),
+            )
+        )
+        return result.scalars().first()
+
+    async def _restore_spool_usage(
+        self,
+        db: AsyncSession,
+        usage_payload,
+        tally: _CategoryTally,
+        spool_id_map: dict[int, int],
+        archive_id_map: dict[int, int],
+    ) -> None:
+        usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+        if not isinstance(usage, list) or not usage:
+            return
+
+        valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
+        unresolved = 0
+
+        for entry in usage:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_spool_id = entry.get("spool_id")
+            spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
+            if spool_id is None:
+                # The parent spool never made it into the map: the backup's spool
+                # list didn't include it, or its entry carried no integer id. A
+                # spool that was merely *skipped* (matched locally, overwrite off)
+                # is mapped a few lines up in _restore_spools, so it never lands
+                # here — which is why the note below offers no remedy.
+                unresolved += 1
+                tally.skipped += 1
+                continue
+
+            created_at = _parse_dt(entry.get("created_at"))
+            # Usage history has no natural key of its own, so dedupe on the
+            # tuple that makes a consumption event unique in practice.
+            existing = await db.execute(
+                select(SpoolUsageHistory).where(
+                    SpoolUsageHistory.spool_id == spool_id,
+                    SpoolUsageHistory.created_at == created_at,
+                    SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
+                    SpoolUsageHistory.print_name == entry.get("print_name"),
+                )
+            )
+            if existing.scalars().first() is not None:
+                tally.skipped += 1
+                continue
+
+            printer_id = entry.get("printer_id")
+            if printer_id is not None and printer_id not in valid_printers:
+                printer_id = None
+
+            old_archive_id = entry.get("archive_id")
+            archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
+
+            row = SpoolUsageHistory(
+                spool_id=spool_id,
+                printer_id=printer_id,
+                print_name=entry.get("print_name"),
+                archive_id=archive_id,
+                weight_used=entry.get("weight_used") or 0,
+                percent_used=entry.get("percent_used") or 0,
+                status=entry.get("status") or "completed",
+                cost=entry.get("cost"),
+            )
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            tally.restored += 1
+
+        if unresolved:
+            tally.note(
+                f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
+                "spool list, so there is nothing to attach them to."
+            )
+
+    async def _restore_settings(self, db: AsyncSession, payload, overwrite: bool, tally: _CategoryTally) -> None:
+        values = payload.get("settings") if isinstance(payload, dict) else None
+        if not isinstance(values, dict):
+            tally.note("No settings data in this backup")
+            return
+
+        blocked = 0
+        for key, value in values.items():
+            if not isinstance(key, str) or not key:
+                tally.failed += 1
+                continue
+            if _is_blocked_setting_key(key):
+                blocked += 1
+                tally.skipped += 1
+                continue
+            if value is None:
+                tally.skipped += 1
+                continue
+
+            result = await db.execute(select(Settings).where(Settings.key == key))
+            existing = result.scalar_one_or_none()
+            if existing is not None:
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                existing.value = str(value)
+                tally.restored += 1
+                continue
+
+            db.add(Settings(key=key, value=str(value)))
+            tally.restored += 1
+
+        if blocked:
+            tally.note(f"{blocked} credential-like key(s) skipped — re-enter secrets manually")
+
+    async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
+        by_serial: dict[str, list[tuple[str, dict]]] = {}
+        for path, content in payload.items():
+            match = _KPROFILE_PATH_RE.match(path)
+            if not match or not isinstance(content, dict):
+                continue
+            by_serial.setdefault(match.group(1), []).append((match.group(2), content))
+
+        if not by_serial:
+            tally.note("No K-profile data in this backup")
+            return
+
+        result = await db.execute(select(Printer))
+        printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
+
+        # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
+        # the profile occupying a slot, so writing is always an overwrite on the
+        # printer side.
+        tally.note("K-profiles always overwrite the matching slot on the printer")
+        tally.note("Profiles are published over MQTT without acknowledgement — verify on the printer")
+
+        for serial, entries in sorted(by_serial.items()):
+            profile_total = sum(len(c.get("profiles") or []) for _, c in entries)
+
+            printer = printers.get(serial)
+            if printer is None:
+                tally.skipped += profile_total
+                tally.note(f"No printer with serial {serial} — skipped")
+                continue
+
+            client = printer_manager.get_client(printer.id)
+            if not client or not client.state.connected:
+                tally.skipped += profile_total
+                tally.note(f"{printer.name} ({serial}) is not connected — skipped")
+                continue
+
+            for nozzle, content in sorted(entries):
+                profiles = content.get("profiles")
+                if not isinstance(profiles, list) or not profiles:
+                    continue
+                if nozzle not in _KNOWN_NOZZLES:
+                    tally.note(f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is")
+
+                profile_dicts = [
+                    {
+                        "filament_id": p.get("filament_id", ""),
+                        "name": p.get("name", ""),
+                        "k_value": p.get("k_value", "0.020000"),
+                        "nozzle_id": p.get("nozzle_id"),
+                        "extruder_id": p.get("extruder_id", 0),
+                        "setting_id": p.get("setting_id"),
+                        "slot_id": p.get("slot_id", 0),
+                    }
+                    for p in profiles
+                    if isinstance(p, dict)
+                ]
+                if not profile_dicts:
+                    continue
+
+                try:
+                    sent = client.set_kprofiles_batch(profile_dicts, nozzle)
+                except Exception as e:
+                    logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
+                    sent = False
+
+                if sent:
+                    tally.restored += len(profile_dicts)
+                else:
+                    tally.failed += len(profile_dicts)
+                    tally.note(f"Failed to send {nozzle} profiles to {printer.name} ({serial})")
+
+
+# Singleton instance
+github_restore_service = GitHubRestoreService()

+ 276 - 0
backend/tests/integration/test_github_restore_api.py

@@ -0,0 +1,276 @@
+"""Integration tests for the Git backup restore API endpoints (#2656)."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture(autouse=True)
+def _mock_private_repo_check():
+    """POST /config refuses to save unless the repo is confirmed private."""
+    with patch(
+        "backend.app.services.github_backup.github_backup_service.test_connection",
+        new=AsyncMock(
+            return_value={
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": "test/repo",
+                "permissions": {"push": True},
+                "is_private": True,
+            }
+        ),
+    ) as m:
+        yield m
+
+
+async def _create_config(async_client: AsyncClient) -> dict:
+    response = await async_client.post(
+        "/api/v1/github-backup/config",
+        json={
+            "repository_url": "https://github.com/test/repo",
+            "access_token": "ghp_testtoken123",
+            "branch": "main",
+            "backup_kprofiles": True,
+            "backup_spools": True,
+            "backup_archives": True,
+            "backup_settings": True,
+            "enabled": True,
+        },
+    )
+    assert response.status_code == 200
+    return response.json()
+
+
+class TestCommitsEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_not_configured(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/github-backup/commits")
+        assert response.status_code == 404
+        assert "Configure backup first" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_commits_from_the_provider(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        commits = [
+            {"sha": "aaa1111", "message": "Bambuddy backup", "author": "Bambuddy", "date": "2026-07-02T10:00:00Z"}
+        ]
+        with patch(
+            "backend.app.services.git_providers.github.GitHubBackend.list_commits",
+            new=AsyncMock(return_value={"success": True, "message": "OK", "commits": commits}),
+        ):
+            response = await async_client.get("/api/v1/github-backup/commits")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["success"] is True
+        assert body["branch"] == "main"
+        assert body["commits"][0]["sha"] == "aaa1111"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_provider_failure_is_reported_not_raised(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        with patch(
+            "backend.app.services.git_providers.github.GitHubBackend.list_commits",
+            new=AsyncMock(return_value={"success": False, "message": "Invalid access token", "commits": []}),
+        ):
+            response = await async_client.get("/api/v1/github-backup/commits")
+
+        assert response.status_code == 200
+        assert response.json()["success"] is False
+        assert response.json()["commits"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_limit_is_bounded(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        assert (await async_client.get("/api/v1/github-backup/commits?limit=0")).status_code == 422
+        assert (await async_client.get("/api/v1/github-backup/commits?limit=101")).status_code == 422
+
+
+class TestPreviewEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_not_configured(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/github-backup/restore/preview")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_available_and_missing_categories(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        preview = {
+            "success": True,
+            "message": "OK",
+            "ref": "aaa1111",
+            "commit": None,
+            "metadata_version": "1.0",
+            "categories": [
+                {"category": "kprofiles", "available": False, "item_count": 0, "detail": "Not present"},
+                {"category": "settings", "available": True, "item_count": 12, "detail": None},
+                {"category": "spools", "available": True, "item_count": 4, "detail": "plus 9 usage records"},
+                {"category": "archives", "available": True, "item_count": 30, "detail": "Metadata only"},
+            ],
+        }
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.preview",
+            new=AsyncMock(return_value=preview),
+        ):
+            response = await async_client.get("/api/v1/github-backup/restore/preview?ref=aaa1111")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["metadata_version"] == "1.0"
+        by_name = {c["category"]: c for c in body["categories"]}
+        assert by_name["kprofiles"]["available"] is False
+        assert by_name["spools"]["item_count"] == 4
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("ref", ["main", "abc", "../../etc/passwd", "zzzzzzz"])
+    async def test_rejects_refs_that_are_not_object_names(self, async_client: AsyncClient, ref):
+        await _create_config(async_client)
+        response = await async_client.get(f"/api/v1/github-backup/restore/preview?ref={ref}")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_head(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        mock = AsyncMock(return_value={"success": True, "message": "OK", "ref": "aaa1111", "categories": []})
+        with patch("backend.app.services.github_restore.github_restore_service.preview", new=mock):
+            response = await async_client.get("/api/v1/github-backup/restore/preview")
+
+        assert response.status_code == 200
+        assert mock.await_args.kwargs["ref"] == "HEAD"
+
+
+class TestRestoreEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_not_configured(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_applies_selected_categories(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        outcome = {
+            "success": True,
+            "message": "Restored 5 item(s) from aaa1111",
+            "log_id": 3,
+            "ref": "aaa1111",
+            "results": {
+                "spools": {"restored": 4, "skipped": 1, "failed": 0, "notes": []},
+                "settings": {"restored": 1, "skipped": 2, "failed": 0, "notes": ["1 credential-like key(s) skipped"]},
+            },
+        }
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(return_value=outcome),
+        ) as mock:
+            response = await async_client.post(
+                "/api/v1/github-backup/restore",
+                json={"ref": "aaa1111", "categories": ["spools", "settings"], "overwrite_existing": True},
+            )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["results"]["spools"]["restored"] == 4
+        assert body["results"]["settings"]["notes"] == ["1 credential-like key(s) skipped"]
+        assert mock.await_args.kwargs["overwrite_existing"] is True
+        assert mock.await_args.kwargs["ref"] == "aaa1111"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_empty_category_list(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.post("/api/v1/github-backup/restore", json={"categories": []})
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_unknown_category(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["cloud_profiles"]})
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_malformed_ref(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.post(
+            "/api/v1/github-backup/restore", json={"ref": "main", "categories": ["spools"]}
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_overwrite_to_false(self, async_client: AsyncClient):
+        """The safe default: a restore only inserts what's missing."""
+        await _create_config(async_client)
+        mock = AsyncMock(return_value={"success": True, "message": "ok", "results": {}})
+        with patch("backend.app.services.github_restore.github_restore_service.run_restore", new=mock):
+            response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
+
+        assert response.status_code == 200
+        assert mock.await_args.kwargs["overwrite_existing"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_service_failure_is_reported_in_body(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        with patch(
+            "backend.app.services.github_restore.github_restore_service.run_restore",
+            new=AsyncMock(
+                return_value={
+                    "success": False,
+                    "message": "A backup is currently running. Wait for it to finish before restoring.",
+                    "results": {},
+                }
+            ),
+        ):
+            response = await async_client.post("/api/v1/github-backup/restore", json={"categories": ["spools"]})
+
+        assert response.status_code == 200
+        assert response.json()["success"] is False
+        assert "backup is currently running" in response.json()["message"]
+
+
+class TestStatusExposesRestoreState:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restore_running_is_false_when_idle(self, async_client: AsyncClient):
+        await _create_config(async_client)
+        response = await async_client.get("/api/v1/github-backup/status")
+        assert response.status_code == 200
+        assert response.json()["restore_running"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restore_running_is_reported(self, async_client: AsyncClient):
+        """The UI disables both action buttons off this flag."""
+        await _create_config(async_client)
+        from backend.app.services.github_restore import github_restore_service
+
+        github_restore_service._running_restore = True
+        github_restore_service._progress = "Restoring spool inventory..."
+        try:
+            response = await async_client.get("/api/v1/github-backup/status")
+        finally:
+            github_restore_service._running_restore = False
+            github_restore_service._progress = None
+
+        assert response.json()["restore_running"] is True
+        assert response.json()["progress"] == "Restoring spool inventory..."
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unconfigured_status_still_has_the_field(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/github-backup/status")
+        assert response.status_code == 200
+        assert response.json()["restore_running"] is False

+ 431 - 0
backend/tests/unit/test_git_providers_restore.py

@@ -0,0 +1,431 @@
+"""Unit tests for the git_providers read side used by restore (#2656).
+
+Covers list_commits / list_tree / fetch_files across all four providers,
+including that Gitea and Forgejo inherit GitHub's Git Data API implementation
+rather than needing their own.
+"""
+
+import base64
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from backend.app.services.git_providers.forgejo import ForgejoBackend
+from backend.app.services.git_providers.gitea import GiteaBackend
+from backend.app.services.git_providers.github import GitHubBackend
+from backend.app.services.git_providers.gitlab import GitLabBackend
+
+
+def _make_mock_response(status_code: int, body=None, text: str = ""):
+    resp = MagicMock()
+    resp.status_code = status_code
+    resp.text = text
+    resp.json = MagicMock(return_value=body if body is not None else {})
+    return resp
+
+
+def _b64(text: str) -> str:
+    return base64.b64encode(text.encode("utf-8")).decode()
+
+
+def _github_commit(sha: str, message: str = "Bambuddy backup", date: str = "2026-07-01T10:00:00Z"):
+    return {"sha": sha, "commit": {"message": message, "author": {"name": "Bambuddy", "date": date}}}
+
+
+class TestGitHubListCommits:
+    def setup_method(self):
+        self.backend = GitHubBackend()
+        self.repo_url = "https://github.com/owner/repo"
+        self.token = "ghp_token"
+
+    @pytest.mark.asyncio
+    async def test_returns_normalised_commits_newest_first(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                [
+                    _github_commit("aaa111", "Bambuddy backup - newest", "2026-07-02T10:00:00Z"),
+                    _github_commit("bbb222", "Bambuddy backup - older", "2026-07-01T10:00:00Z"),
+                ],
+            )
+        )
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert result["success"] is True
+        assert [c["sha"] for c in result["commits"]] == ["aaa111", "bbb222"]
+        assert result["commits"][0]["message"] == "Bambuddy backup - newest"
+        assert result["commits"][0]["author"] == "Bambuddy"
+        assert result["commits"][0]["date"] == "2026-07-02T10:00:00Z"
+
+    @pytest.mark.asyncio
+    async def test_sends_both_per_page_and_limit(self):
+        """GitHub honours per_page, Gitea honours limit — one call must carry both
+        so GiteaBackend can inherit this method unchanged."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, []))
+
+        await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=7)
+
+        params = client.get.await_args.kwargs["params"]
+        assert params["per_page"] == 7
+        assert params["limit"] == 7
+        assert params["sha"] == "main"
+
+    @pytest.mark.asyncio
+    async def test_respects_limit_even_if_provider_overshoots(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit(f"sha{i}") for i in range(10)]))
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client, limit=3)
+
+        assert len(result["commits"]) == 3
+
+    @pytest.mark.asyncio
+    async def test_404_explains_empty_repository(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "nope", client)
+
+        assert result["success"] is False
+        assert "no commits yet" in result["message"]
+        assert result["commits"] == []
+
+    @pytest.mark.asyncio
+    async def test_skips_entries_without_a_sha(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(200, [{"commit": {"message": "no sha"}}, _github_commit("good")])
+        )
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert [c["sha"] for c in result["commits"]] == ["good"]
+
+    @pytest.mark.asyncio
+    async def test_non_list_body_is_an_error_not_a_crash(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"unexpected": "shape"}))
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert result["success"] is False
+        assert "Unexpected shape" in result["message"]
+
+
+class TestGitHubListTree:
+    def setup_method(self):
+        self.backend = GitHubBackend()
+        self.repo_url = "https://github.com/owner/repo"
+        self.token = "ghp_token"
+
+    @pytest.mark.asyncio
+    async def test_returns_sorted_blob_paths_only(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                {
+                    "tree": [
+                        {"type": "blob", "path": "spools/inventory.json", "sha": "s1"},
+                        {"type": "tree", "path": "spools", "sha": "d1"},
+                        {"type": "blob", "path": "backup_metadata.json", "sha": "m1"},
+                    ]
+                },
+            )
+        )
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["success"] is True
+        assert result["paths"] == ["backup_metadata.json", "spools/inventory.json"]
+
+    @pytest.mark.asyncio
+    async def test_truncated_tree_fails_loudly(self):
+        """A truncated listing would make restore silently miss categories."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": [], "truncated": True}))
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["success"] is False
+        assert "truncated" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_404_names_the_missing_ref(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "deadbee", client)
+
+        assert result["success"] is False
+        assert "deadbee" in result["message"]
+
+
+class TestGitHubFetchFiles:
+    def setup_method(self):
+        self.backend = GitHubBackend()
+        self.repo_url = "https://github.com/owner/repo"
+        self.token = "ghp_token"
+
+    @pytest.mark.asyncio
+    async def test_reads_requested_paths_via_blob_api(self):
+        tree = _make_mock_response(
+            200,
+            {
+                "tree": [
+                    {"type": "blob", "path": "a.json", "sha": "sha-a"},
+                    {"type": "blob", "path": "b.json", "sha": "sha-b"},
+                ]
+            },
+        )
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                tree,
+                _make_mock_response(200, {"content": _b64('{"a": 1}'), "encoding": "base64"}),
+            ]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {"a.json": '{"a": 1}'}
+        # One tree listing regardless of how many files are read.
+        assert client.get.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_lists_the_tree_once_for_many_files(self):
+        tree = _make_mock_response(
+            200,
+            {
+                "tree": [
+                    {"type": "blob", "path": "a.json", "sha": "sha-a"},
+                    {"type": "blob", "path": "b.json", "sha": "sha-b"},
+                ]
+            },
+        )
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                tree,
+                _make_mock_response(200, {"content": _b64("1"), "encoding": "base64"}),
+                _make_mock_response(200, {"content": _b64("2"), "encoding": "base64"}),
+            ]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json", "b.json"], client)
+
+        assert result["files"] == {"a.json": "1", "b.json": "2"}
+        assert client.get.await_count == 3
+
+    @pytest.mark.asyncio
+    async def test_missing_path_is_skipped_not_an_error(self):
+        """Which categories a backup contains varies by config, so an absent
+        path is expected rather than a failure."""
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {}
+
+    @pytest.mark.asyncio
+    async def test_blob_error_fails_the_whole_read(self):
+        tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
+        client = AsyncMock()
+        client.get = AsyncMock(side_effect=[tree, _make_mock_response(500, {}, text="boom")])
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is False
+        assert "a.json" in result["message"]
+        assert result["files"] == {}
+
+    @pytest.mark.asyncio
+    async def test_utf8_content_survives_round_trip(self):
+        payload = '{"color_name": "Jadeweiß", "note": "日本語"}'
+        tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[tree, _make_mock_response(200, {"content": _b64(payload), "encoding": "base64"})]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["files"]["a.json"] == payload
+
+    @pytest.mark.asyncio
+    async def test_unsupported_encoding_is_reported(self):
+        tree = _make_mock_response(200, {"tree": [{"type": "blob", "path": "a.json", "sha": "sha-a"}]})
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[tree, _make_mock_response(200, {"content": "xx", "encoding": "quoted-printable"})]
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is False
+        assert "Unsupported blob encoding" in result["message"]
+
+
+class TestGiteaAndForgejoInheritReads:
+    """Gitea overrides the *write* path only; reads come from GitHubBackend."""
+
+    @pytest.mark.parametrize("backend_cls", [GiteaBackend, ForgejoBackend])
+    def test_read_methods_are_not_overridden(self, backend_cls):
+        for method in ("list_commits", "list_tree", "fetch_files"):
+            assert getattr(backend_cls, method) is getattr(GitHubBackend, method)
+
+    @pytest.mark.asyncio
+    async def test_gitea_list_commits_uses_its_own_api_base(self):
+        backend = GiteaBackend()
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, [_github_commit("abc")]))
+
+        result = await backend.list_commits("https://git.example.com/owner/repo", "tok", "main", client)
+
+        assert result["success"] is True
+        url = client.get.await_args.args[0]
+        assert url.startswith("https://git.example.com/api/v1/repos/owner/repo/commits")
+
+    @pytest.mark.asyncio
+    async def test_gitea_subpath_install_is_respected(self):
+        """Gitea/Forgejo behind a ROOT_URL sub-path (#2642)."""
+        backend = GiteaBackend()
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"tree": []}))
+
+        await backend.list_tree("https://example.com/git/owner/repo", "tok", "abc1234", client)
+
+        url = client.get.await_args.args[0]
+        assert "/git/api/v1/repos/owner/repo/git/trees/abc1234" in url
+
+
+class TestGitLabReads:
+    def setup_method(self):
+        self.backend = GitLabBackend()
+        self.repo_url = "https://gitlab.com/owner/repo"
+        self.token = "glpat-test"
+
+    @pytest.mark.asyncio
+    async def test_list_commits_reads_flattened_author_fields(self):
+        """GitLab puts message/author/date on the entry, not under 'commit'."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                [
+                    {
+                        "id": "abc123",
+                        "message": "Bambuddy backup",
+                        "author_name": "Bambuddy",
+                        "committed_date": "2026-07-02T10:00:00Z",
+                    }
+                ],
+            )
+        )
+
+        result = await self.backend.list_commits(self.repo_url, self.token, "main", client)
+
+        assert result["success"] is True
+        assert result["commits"] == [
+            {
+                "sha": "abc123",
+                "message": "Bambuddy backup",
+                "author": "Bambuddy",
+                "date": "2026-07-02T10:00:00Z",
+            }
+        ]
+
+    @pytest.mark.asyncio
+    async def test_list_commits_uses_ref_name(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, []))
+
+        await self.backend.list_commits(self.repo_url, self.token, "bambuddy-backup", client, limit=5)
+
+        params = client.get.await_args.kwargs["params"]
+        assert params["ref_name"] == "bambuddy-backup"
+        assert params["per_page"] == 5
+
+    @pytest.mark.asyncio
+    async def test_subgroup_path_is_url_encoded(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, []))
+
+        await self.backend.list_commits("https://gitlab.com/group/subgroup/proj", self.token, "main", client)
+
+        url = client.get.await_args.args[0]
+        assert "projects/group%2Fsubgroup%2Fproj/repository/commits" in url
+
+    @pytest.mark.asyncio
+    async def test_list_tree_returns_blob_paths(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(
+                200,
+                [
+                    {"type": "blob", "path": "spools/inventory.json"},
+                    {"type": "tree", "path": "spools"},
+                ],
+            )
+        )
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert result["success"] is True
+        assert result["paths"] == ["spools/inventory.json"]
+
+    @pytest.mark.asyncio
+    async def test_list_tree_follows_pagination(self):
+        """GitLab paginates instead of exposing a truncated flag."""
+        full_page = [{"type": "blob", "path": f"f{i}.json"} for i in range(100)]
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(200, full_page),
+                _make_mock_response(200, [{"type": "blob", "path": "last.json"}]),
+            ]
+        )
+
+        result = await self.backend.list_tree(self.repo_url, self.token, "abc1234", client)
+
+        assert client.get.await_count == 2
+        assert len(result["paths"]) == 101
+        assert "last.json" in result["paths"]
+
+    @pytest.mark.asyncio
+    async def test_fetch_files_decodes_base64(self):
+        client = AsyncMock()
+        client.get = AsyncMock(
+            return_value=_make_mock_response(200, {"content": _b64('{"k": 1}'), "encoding": "base64"})
+        )
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["a.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {"a.json": '{"k": 1}'}
+
+    @pytest.mark.asyncio
+    async def test_fetch_files_encodes_nested_path(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(200, {"content": _b64("{}"), "encoding": "base64"}))
+
+        await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["spools/inventory.json"], client)
+
+        url = client.get.await_args.args[0]
+        assert "repository/files/spools%2Finventory.json" in url
+
+    @pytest.mark.asyncio
+    async def test_fetch_files_skips_404(self):
+        client = AsyncMock()
+        client.get = AsyncMock(return_value=_make_mock_response(404, {}))
+
+        result = await self.backend.fetch_files(self.repo_url, self.token, "abc1234", ["gone.json"], client)
+
+        assert result["success"] is True
+        assert result["files"] == {}

+ 946 - 0
backend/tests/unit/test_github_restore.py

@@ -0,0 +1,946 @@
+"""Unit tests for the Git backup restore service (#2656).
+
+Focus is on the per-category appliers: natural-key matching, the deliberate
+refusal to reuse the backup's primary keys, old_id -> new_id remapping for
+dependent rows, overwrite-vs-skip, the settings credential blocklist, and the
+K-profile paths that depend on live printers.
+"""
+
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.schemas.github_backup import GitHubRestoreRequest, RestoreCategory
+from backend.app.services.github_restore import (
+    ARCHIVES_PATH,
+    SETTINGS_PATH,
+    SPOOL_USAGE_PATH,
+    SPOOLS_PATH,
+    GitHubRestoreService,
+    _CategoryTally,
+    _is_blocked_setting_key,
+    _parse_dt,
+)
+
+
+def _service() -> GitHubRestoreService:
+    return GitHubRestoreService()
+
+
+class TestParseDt:
+    def test_parses_str_datetime_the_backup_writes(self):
+        assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456)
+
+    def test_parses_iso_with_t_separator(self):
+        assert _parse_dt("2026-07-27T06:02:05") == datetime(2026, 7, 27, 6, 2, 5)
+
+    @pytest.mark.parametrize("value", ["", None, "not a date", 12345, {}])
+    def test_returns_none_for_junk(self, value):
+        assert _parse_dt(value) is None
+
+
+class TestSettingKeyBlocklist:
+    @pytest.mark.parametrize(
+        "key",
+        [
+            "bambu_cloud_token",
+            "auth_secret_key",
+            "ha_token",
+            "prometheus_token",
+            "printer_access_code",
+            "smtp_password",
+            "some_api_key",
+            "ftp_passphrase",
+            "MQTT_SECRET",
+        ],
+    )
+    def test_credential_like_keys_are_blocked(self, key):
+        assert _is_blocked_setting_key(key) is True
+
+    @pytest.mark.parametrize(
+        "key",
+        ["low_stock_threshold", "currency", "theme", "local_backup_enabled", "timezone"],
+    )
+    def test_ordinary_keys_are_allowed(self, key):
+        assert _is_blocked_setting_key(key) is False
+
+
+class TestCategoryTally:
+    def test_notes_are_deduplicated(self):
+        tally = _CategoryTally()
+        tally.note("same")
+        tally.note("same")
+        assert tally.notes == ["same"]
+
+    def test_notes_are_bounded(self):
+        tally = _CategoryTally()
+        for i in range(50):
+            tally.note(f"note {i}")
+        assert len(tally.notes) == 20
+
+
+class TestRestoreRequestSchema:
+    def test_rejects_empty_category_list(self):
+        with pytest.raises(ValueError):
+            GitHubRestoreRequest(categories=[])
+
+    def test_deduplicates_categories(self):
+        request = GitHubRestoreRequest(
+            categories=[RestoreCategory.SPOOLS, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
+        )
+        assert request.categories == [RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
+
+    def test_defaults_to_head(self):
+        assert GitHubRestoreRequest(categories=[RestoreCategory.SPOOLS]).ref == "HEAD"
+
+    @pytest.mark.parametrize("ref", ["HEAD", "abc1234", "a" * 40])
+    def test_accepts_valid_refs(self, ref):
+        assert GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]).ref == ref
+
+    @pytest.mark.parametrize("ref", ["abc", "main", "../etc/passwd", "a" * 41, "zzzzzzz", "abc 123"])
+    def test_rejects_refs_that_are_not_object_names(self, ref):
+        with pytest.raises(ValueError):
+            GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS])
+
+
+class TestRestoreSettings:
+    @pytest.mark.asyncio
+    async def test_inserts_missing_keys(self, db_session):
+        tally = _CategoryTally()
+        payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}}
+
+        await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally)
+        await db_session.commit()
+
+        rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
+        assert rows == {"currency": "EUR", "theme": "dark"}
+        assert tally.restored == 2
+
+    @pytest.mark.asyncio
+    async def test_skips_existing_key_when_overwrite_off(self, db_session):
+        db_session.add(Settings(key="currency", value="USD"))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=False, tally=tally)
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
+        assert row.value == "USD"
+        assert tally.skipped == 1
+        assert tally.restored == 0
+
+    @pytest.mark.asyncio
+    async def test_overwrites_existing_key_when_enabled(self, db_session):
+        db_session.add(Settings(key="currency", value="USD"))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=True, tally=tally)
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
+        assert row.value == "EUR"
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_credential_keys_are_never_restored(self, db_session):
+        """A backup predating the collector's denylist can still contain secrets."""
+        tally = _CategoryTally()
+        payload = {"settings": {"currency": "EUR", "bambu_cloud_token": "leaked", "ha_token": "leaked"}}
+
+        await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
+        await db_session.commit()
+
+        keys = {s.key for s in (await db_session.execute(select(Settings))).scalars().all()}
+        assert keys == {"currency"}
+        assert tally.skipped == 2
+        assert any("credential-like" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_missing_payload_is_noted_not_fatal(self, db_session):
+        tally = _CategoryTally()
+        await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
+        assert tally.restored == 0
+        assert tally.notes
+
+
+class TestRestoreSpools:
+    def _spool_entry(self, **overrides):
+        entry = {
+            "id": 41,
+            "material": "PLA",
+            "subtype": "Basic",
+            "color_name": "Jade White",
+            "brand": "Bambu Lab",
+            "tag_uid": "AABBCCDD",
+            "created_at": "2026-01-05 12:00:00",
+            "weight_used": 120.5,
+        }
+        entry.update(overrides)
+        return entry
+
+    @pytest.mark.asyncio
+    async def test_inserts_without_reusing_backup_id(self, db_session):
+        """The backup's spool.id belongs to an unrelated row today."""
+        db_session.add(Spool(material="PETG"))  # occupies id 1
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        payload = {"spools": [self._spool_entry(id=1)]}
+
+        await _service()._restore_spools(db_session, payload, None, False, tally, {})
+        await db_session.commit()
+
+        spools = (await db_session.execute(select(Spool))).scalars().all()
+        assert len(spools) == 2
+        restored = next(s for s in spools if s.tag_uid == "AABBCCDD")
+        assert restored.id != 1
+        assert restored.material == "PLA"
+
+    @pytest.mark.asyncio
+    async def test_matches_existing_spool_by_tag_uid(self, db_session):
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_matches_existing_spool_by_tray_uuid(self, db_session):
+        db_session.add(Spool(material="PLA", tray_uuid="1234" * 8))
+        await db_session.commit()
+        tally = _CategoryTally()
+        entry = self._spool_entry(tag_uid=None, tray_uuid="1234" * 8)
+
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_matches_tagless_spool_by_descriptive_composite(self, db_session):
+        """Manually added spools have no tag, so fall back to created_at + description."""
+        db_session.add(
+            Spool(
+                material="PLA",
+                subtype="Basic",
+                color_name="Jade White",
+                brand="Bambu Lab",
+                created_at=datetime(2026, 1, 5, 12, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._spool_entry(tag_uid=None)
+        await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_overwrite_updates_matched_spool(self, db_session):
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old", weight_used=0))
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(Spool))).scalar_one()
+        assert row.color_name == "Jade White"
+        assert row.weight_used == 120.5
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_insert_preserves_created_at_so_repeat_restore_is_idempotent(self, db_session):
+        """Second restore of the same backup must match, not duplicate."""
+        service = _service()
+        payload = {"spools": [self._spool_entry(tag_uid=None)]}
+
+        await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
+        await db_session.commit()
+        await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        spools = (await db_session.execute(select(Spool))).scalars().all()
+        assert len(spools) == 1
+        assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
+
+    @pytest.mark.asyncio
+    async def test_usage_history_spool_id_is_remapped(self, db_session):
+        """Usage rows must point at the new local spool id, not the backup's."""
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {
+                    "id": 900,
+                    "spool_id": 41,
+                    "printer_id": None,
+                    "print_name": "benchy.3mf",
+                    "archive_id": None,
+                    "weight_used": 12.0,
+                    "percent_used": 5,
+                    "status": "completed",
+                    "created_at": "2026-02-01 09:00:00",
+                }
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        spool = (await db_session.execute(select(Spool))).scalar_one()
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.spool_id == spool.id
+        assert row.print_name == "benchy.3mf"
+
+    @pytest.mark.asyncio
+    async def test_usage_history_archive_id_is_remapped(self, db_session):
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {
+                    "spool_id": 41,
+                    "archive_id": 77,
+                    "weight_used": 1.0,
+                    "created_at": "2026-02-01 09:00:00",
+                }
+            ]
+        }
+        archive = PrintArchive(filename="a.3mf", file_path="", file_size=1)
+        db_session.add(archive)
+        await db_session.flush()
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {77: archive.id})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.archive_id == archive.id
+
+    @pytest.mark.asyncio
+    async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session):
+        tally = _CategoryTally()
+        usage = {"usage_history": [{"spool_id": 999, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}]}
+
+        await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {})
+        await db_session.commit()
+
+        assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
+        assert tally.skipped == 1
+        assert any("their spool is not in this backup's spool list" in note for note in tally.notes)
+        # No remedy is offered, because none exists: overwrite does not change
+        # which spools land in the map (a skipped spool is mapped anyway), and
+        # usage history is always restored alongside the spools category.
+        assert not any("overwrite" in note.lower() for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
+        """A skipped spool is still mapped, so its usage rows are not "unresolved".
+
+        This is why the note above offers no remedy: turning overwrite on would
+        not rescue anything, and saying so misdescribed which records are lost.
+        """
+        db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
+        await db_session.commit()
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        spool = (await db_session.execute(select(Spool))).scalar_one()
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.spool_id == spool.id
+        assert not any("spool list" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
+        service = _service()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
+            ]
+        }
+
+        await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
+        await db_session.commit()
+        await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
+        await db_session.commit()
+
+        rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
+        assert len(rows) == 1
+
+    @pytest.mark.asyncio
+    async def test_dangling_printer_id_is_cleared(self, db_session):
+        tally = _CategoryTally()
+        inventory = {"spools": [self._spool_entry(id=41)]}
+        usage = {
+            "usage_history": [
+                {"spool_id": 41, "printer_id": 4242, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
+            ]
+        }
+
+        await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
+        assert row.printer_id is None
+
+
+class TestRestoreArchives:
+    def _archive_entry(self, **overrides):
+        entry = {
+            "id": 77,
+            "filename": "benchy.3mf",
+            "file_size": 2048,
+            "content_hash": "abc123",
+            "print_name": "Benchy",
+            "status": "completed",
+            "started_at": "2026-03-01 10:00:00",
+            "completed_at": "2026-03-01 11:00:00",
+            "created_at": "2026-03-01 10:00:00",
+            "quantity": 1,
+            "is_favorite": False,
+        }
+        entry.update(overrides)
+        return entry
+
+    @pytest.mark.asyncio
+    async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session):
+        """print_archives.file_path is NOT NULL but is not in the backup."""
+        tally = _CategoryTally()
+        id_map: dict[int, int] = {}
+
+        await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, id_map)
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.file_path == ""
+        assert row.filename == "benchy.3mf"
+        assert row.id != 77
+        assert id_map == {77: row.id}
+        assert any("metadata only" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_matches_existing_archive_by_hash_and_start(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
+        await db_session.commit()
+
+        rows = (await db_session.execute(select(PrintArchive))).scalars().all()
+        assert len(rows) == 1
+        assert rows[0].file_path == "/data/benchy.3mf"
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_filename_and_start_without_hash(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(content_hash=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_matches_archive_with_no_started_at_by_hash(self, db_session):
+        """started_at is NULL for re-sliced archives, so it cannot be required.
+
+        Gating both match branches on it meant these rows never matched: every
+        restore re-inserted them and overwrite mode could never update them.
+        """
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=None,
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(started_at=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_started_at_still_discriminates_when_present(self, db_session):
+        """A NULL-tolerant match must not collapse rows that do differ."""
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        # Same file, no start time recorded — a different row, not that one.
+        entry = self._archive_entry(started_at=None)
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 2
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_soft_deleted_archive_is_not_restored_as_visible(self, db_session):
+        """A backup keeps soft-deleted rows, so the flag has to survive.
+
+        Their row is retained on purpose (stats keep counting the filament and
+        energy), so without carrying deleted_at a restore turns an archive the
+        user deleted back into a visible one.
+        """
+        tally = _CategoryTally()
+        entry = self._archive_entry(deleted_at="2026-03-02 08:00:00")
+
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at == datetime(2026, 3, 2, 8, 0, 0)
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_locally_deleted_archive_stays_deleted_without_overwrite(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=datetime(2026, 3, 5, 9, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        # The backup predates the deletion, so its copy is live.
+        await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at == datetime(2026, 3, 5, 9, 0, 0)
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_overwrite_undeletes_a_locally_deleted_archive_and_says_so(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=datetime(2026, 3, 5, 9, 0, 0),
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at is None
+        assert tally.restored == 1
+        assert any("visible again" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                notes="old",
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        entry = self._archive_entry(notes="restored note")
+        await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.notes == "restored note"
+        # The 3MF on disk must not be orphaned by a metadata restore.
+        assert row.file_path == "/data/benchy.3mf"
+        assert tally.restored == 1
+
+    @pytest.mark.asyncio
+    async def test_dangling_printer_and_project_links_are_cleared(self, db_session):
+        tally = _CategoryTally()
+        entry = self._archive_entry(printer_id=4242, project_id=4343)
+
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.printer_id is None
+        assert row.project_id is None
+        assert any("no longer exist" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
+        printer = await printer_factory()
+        tally = _CategoryTally()
+        entry = self._archive_entry(printer_id=printer.id)
+
+        await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.printer_id == printer.id
+
+    @pytest.mark.asyncio
+    async def test_non_dict_entry_counts_as_failed(self, db_session):
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, {"archives": ["nonsense"]}, False, tally, {})
+        assert tally.failed == 1
+
+
+class TestRestoreKprofiles:
+    def _payload(self, serial="00M09A123456789", nozzle="0.4"):
+        return {
+            f"kprofiles/{serial}/{nozzle}.json": {
+                "version": "1.0",
+                "printer_serial": serial,
+                "nozzle_diameter": nozzle,
+                "profiles": [
+                    {
+                        "slot_id": 0,
+                        "name": "Bambu PLA",
+                        "k_value": "0.020000",
+                        "filament_id": "GFA00",
+                        "nozzle_id": "HS00-0.4",
+                        "extruder_id": 0,
+                        "setting_id": "PFUS123",
+                    }
+                ],
+            }
+        }
+
+    @pytest.mark.asyncio
+    async def test_sends_batch_to_connected_printer(self, db_session, printer_factory):
+        printer = await printer_factory(serial_number="00M09A123456789")
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(return_value=True)
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        client.set_kprofiles_batch.assert_called_once()
+        profiles, nozzle = client.set_kprofiles_batch.call_args.args
+        assert nozzle == "0.4"
+        assert profiles[0]["name"] == "Bambu PLA"
+        assert profiles[0]["filament_id"] == "GFA00"
+        assert tally.restored == 1
+        assert manager.get_client.call_args.args == (printer.id,)
+
+    @pytest.mark.asyncio
+    async def test_always_warns_that_mqtt_is_unacknowledged(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(return_value=True)
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert any("without acknowledgement" in note for note in tally.notes)
+        assert any("always overwrite" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_unknown_serial_is_skipped_with_reason(self, db_session):
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager"):
+            await _service()._restore_kprofiles(db_session, self._payload(serial="NOSUCH"), tally)
+
+        assert tally.restored == 0
+        assert tally.skipped == 1
+        assert any("No printer with serial NOSUCH" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
+        client = MagicMock()
+        client.state.connected = False
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.skipped == 1
+        assert tally.failed == 0
+        assert any("not connected" in note for note in tally.notes)
+
+    @pytest.mark.asyncio
+    async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=None)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.skipped == 1
+
+    @pytest.mark.asyncio
+    async def test_publish_failure_counts_as_failed(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(return_value=False)
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.failed == 1
+        assert tally.restored == 0
+
+    @pytest.mark.asyncio
+    async def test_publish_exception_is_contained(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(side_effect=RuntimeError("mqtt down"))
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, self._payload(), tally)
+
+        assert tally.failed == 1
+
+    @pytest.mark.asyncio
+    async def test_each_nozzle_is_sent_separately(self, db_session, printer_factory):
+        await printer_factory(serial_number="00M09A123456789")
+        payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
+        client = MagicMock()
+        client.state.connected = True
+        client.set_kprofiles_batch = MagicMock(return_value=True)
+        tally = _CategoryTally()
+
+        with patch("backend.app.services.github_restore.printer_manager") as manager:
+            manager.get_client = MagicMock(return_value=client)
+            await _service()._restore_kprofiles(db_session, payload, tally)
+
+        assert client.set_kprofiles_batch.call_count == 2
+        assert {c.args[1] for c in client.set_kprofiles_batch.call_args_list} == {"0.4", "0.8"}
+        assert tally.restored == 2
+
+    @pytest.mark.asyncio
+    async def test_empty_payload_is_noted(self, db_session):
+        tally = _CategoryTally()
+        await _service()._restore_kprofiles(db_session, {}, tally)
+        assert any("No K-profile data" in note for note in tally.notes)
+
+
+class TestSoftDeletedArchiveRoundTrip:
+    """The two halves of the soft-delete fix only work together.
+
+    The collector keeps soft-deleted rows on purpose (their stats still count),
+    so if it doesn't write ``deleted_at`` there is nothing for the restore to
+    carry across and a deleted archive comes back visible. Covered end to end
+    because each half looks harmless on its own.
+    """
+
+    @pytest.mark.asyncio
+    async def test_deleted_at_survives_collect_then_restore(self, db_session):
+        from backend.app.services.github_backup import github_backup_service
+
+        deleted_at = datetime(2026, 3, 5, 9, 0, 0)
+        db_session.add(
+            PrintArchive(
+                filename="trashed.3mf",
+                file_path="",
+                file_size=1024,
+                content_hash="hash-trashed",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                deleted_at=deleted_at,
+            )
+        )
+        await db_session.commit()
+
+        files: dict = {}
+        await github_backup_service._collect_archives(db_session, files)
+        payload = files[ARCHIVES_PATH]
+        assert payload["archives"][0]["deleted_at"] == str(deleted_at)
+
+        # Restore that payload into an instance where the row is gone entirely.
+        await db_session.execute(PrintArchive.__table__.delete())
+        await db_session.commit()
+
+        tally = _CategoryTally()
+        await _service()._restore_archives(db_session, payload, False, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.deleted_at == deleted_at, "a deleted archive must not come back visible"
+
+
+class TestCategoryPathMapping:
+    def setup_method(self):
+        self.service = _service()
+        self.available = [
+            "backup_metadata.json",
+            SETTINGS_PATH,
+            SPOOLS_PATH,
+            SPOOL_USAGE_PATH,
+            ARCHIVES_PATH,
+            "kprofiles/SERIAL1/0.4.json",
+            "kprofiles/SERIAL1/0.8.json",
+            "cloud_profiles/filament.json",
+        ]
+
+    def test_spools_includes_usage_history(self):
+        paths = self.service._category_paths(RestoreCategory.SPOOLS, self.available)
+        assert paths == [SPOOLS_PATH, SPOOL_USAGE_PATH]
+
+    def test_kprofiles_globs_all_serials_and_nozzles(self):
+        paths = self.service._category_paths(RestoreCategory.KPROFILES, self.available)
+        assert paths == ["kprofiles/SERIAL1/0.4.json", "kprofiles/SERIAL1/0.8.json"]
+
+    def test_absent_paths_are_omitted(self):
+        paths = self.service._category_paths(RestoreCategory.SETTINGS, ["backup_metadata.json"])
+        assert paths == []
+
+    def test_cloud_profiles_are_not_a_restore_category(self):
+        assert "cloud_profiles" not in {c.value for c in RestoreCategory}
+
+
+class TestMutex:
+    @pytest.mark.asyncio
+    async def test_restore_refuses_while_a_backup_is_running(self):
+        service = _service()
+        with patch("backend.app.services.github_backup.github_backup_service") as backup:
+            backup.is_running = True
+            result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
+
+        assert result["success"] is False
+        assert "backup is currently running" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_restore_refuses_while_another_restore_is_running(self):
+        service = _service()
+        service._running_restore = True
+
+        result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
+
+        assert result["success"] is False
+        assert "restore is already running" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_backup_refuses_while_a_restore_is_running(self):
+        from backend.app.services.github_backup import GitHubBackupService
+
+        backup_service = GitHubBackupService()
+        with patch("backend.app.services.github_restore.github_restore_service") as restore:
+            restore.is_running = True
+            result = await backup_service.run_backup(1, trigger="manual")
+
+        assert result["success"] is False
+        assert "restore is currently running" in result["message"]
+
+
+class TestResolveRef:
+    @pytest.mark.asyncio
+    async def test_concrete_sha_passes_through_without_an_api_call(self):
+        service = _service()
+        service.list_commits = AsyncMock()
+        config = MagicMock(branch="main")
+
+        resolved, error = await service._resolve_ref(config, "abc1234")
+
+        assert resolved == "abc1234"
+        assert error == ""
+        service.list_commits.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_head_resolves_to_the_tip_sha(self):
+        service = _service()
+        service.list_commits = AsyncMock(
+            return_value={"success": True, "commits": [{"sha": "tipsha1"}, {"sha": "older"}]}
+        )
+        config = MagicMock(branch="main")
+
+        resolved, error = await service._resolve_ref(config, "HEAD")
+
+        assert resolved == "tipsha1"
+        assert error == ""
+
+    @pytest.mark.asyncio
+    async def test_empty_history_is_an_error(self):
+        service = _service()
+        service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
+        config = MagicMock(branch="main")
+
+        resolved, error = await service._resolve_ref(config, "HEAD")
+
+        assert resolved is None
+        assert "no commits" in error

+ 367 - 0
frontend/src/__tests__/components/GitHubRestoreModal.test.tsx

@@ -0,0 +1,367 @@
+/**
+ * Tests for the Restore from Git Backup modal (#2656).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { GitHubRestoreModal } from '../../components/GitHubRestoreModal';
+
+const mockCommits = {
+  success: true,
+  message: 'OK',
+  branch: 'main',
+  commits: [
+    {
+      sha: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
+      message: 'Bambuddy backup - 2026-07-02 10:00:00 UTC',
+      author: 'Bambuddy',
+      date: '2026-07-02T10:00:00Z',
+    },
+    {
+      sha: 'bbb2222ccc3333ddd4444eee5555ffff0aaa1111',
+      message: 'Bambuddy backup - 2026-07-01 10:00:00 UTC',
+      author: 'Bambuddy',
+      date: '2026-07-01T10:00:00Z',
+    },
+  ],
+};
+
+const mockPreview = {
+  success: true,
+  message: 'OK',
+  ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
+  commit: mockCommits.commits[0],
+  metadata_version: '1.0',
+  categories: [
+    { category: 'archives', available: true, item_count: 30, detail: 'Metadata only' },
+    { category: 'spools', available: true, item_count: 4, detail: null },
+    { category: 'settings', available: true, item_count: 12, detail: null },
+    { category: 'kprofiles', available: false, item_count: 0, detail: 'Not present in this backup commit' },
+  ],
+};
+
+type JsonBody = Record<string, unknown>;
+
+function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) {
+  server.use(
+    http.get('/api/v1/github-backup/commits', () =>
+      HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody))
+    ),
+    http.get('/api/v1/github-backup/restore/preview', () =>
+      HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody))
+    ),
+  );
+}
+
+describe('GitHubRestoreModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockEndpoints();
+  });
+
+  it('renders the title and commit picker', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument();
+    });
+    expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
+  });
+
+  it('defaults to the latest commit and lists recent commits', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement;
+    expect(select.value).toBe('HEAD');
+    await waitFor(() => {
+      expect(screen.getByText(/Latest backup/)).toBeInTheDocument();
+    });
+    // Commits are labelled by short SHA.
+    await waitFor(() => {
+      expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument();
+      expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument();
+    });
+  });
+
+  it('shows item counts for categories present in the commit', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('30 in backup')).toBeInTheDocument();
+    });
+    expect(screen.getByText('4 in backup')).toBeInTheDocument();
+    expect(screen.getByText('12 in backup')).toBeInTheDocument();
+  });
+
+  it('disables a category that is absent from the commit', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument();
+    });
+
+    const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    // Four categories in fixed order: archives, spools, settings, kprofiles.
+    expect(checkboxes).toHaveLength(4);
+    expect(checkboxes[3].disabled).toBe(true);
+    expect(checkboxes[0].disabled).toBe(false);
+  });
+
+  it('keeps Restore disabled until a category is selected', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const restoreButton = await screen.findByRole('button', { name: /Restore$/ });
+    expect(restoreButton).toBeDisabled();
+
+    // Wait for the preview to populate the category list before selecting.
+    const checkboxes = await waitFor(() => {
+      const found = screen.getAllByRole('checkbox') as HTMLInputElement[];
+      expect(found).toHaveLength(4);
+      return found;
+    });
+    await userEvent.click(checkboxes[1]);
+
+    await waitFor(() => expect(restoreButton).not.toBeDisabled());
+    expect(screen.getByText('1 selected')).toBeInTheDocument();
+  });
+
+  it('requires confirmation before sending the restore', async () => {
+    let restoreCalls = 0;
+    server.use(
+      http.post('/api/v1/github-backup/restore', async () => {
+        restoreCalls += 1;
+        return HttpResponse.json({
+          success: true,
+          message: 'Restored 4 item(s) from aaa1111',
+          log_id: 3,
+          ref: mockPreview.ref,
+          results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } },
+        });
+      })
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+
+    // Confirm dialog appears; nothing sent yet.
+    await waitFor(() => {
+      expect(screen.getByText('Restore from backup?')).toBeInTheDocument();
+    });
+    expect(restoreCalls).toBe(0);
+  });
+
+  it('sends the selected categories and shows per-category results', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('/api/v1/github-backup/restore', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({
+          success: true,
+          message: 'Restored 4 item(s) from aaa1111',
+          log_id: 3,
+          ref: mockPreview.ref,
+          results: {
+            spools: { restored: 4, skipped: 1, failed: 0, notes: ['1 usage record(s) skipped'] },
+          },
+        });
+      })
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => {
+      expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument();
+    });
+    // The commit posted is the sha the preview resolved to, not the symbolic
+    // 'HEAD' the picker defaults to: re-resolving server-side would restore a
+    // backup that landed after the preview the user actually approved.
+    expect(body).toMatchObject({
+      categories: ['spools'],
+      overwrite_existing: false,
+      ref: mockPreview.ref,
+    });
+    expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
+    expect(screen.getByText('1 usage record(s) skipped')).toBeInTheDocument();
+  });
+
+  it('sends overwrite_existing when the toggle is on', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('/api/v1/github-backup/restore', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} });
+      })
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('switch'));
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true }));
+  });
+
+  it('warns more strongly when overwrite is enabled', async () => {
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('switch'));
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+
+    await waitFor(() => {
+      expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument();
+    });
+  });
+
+  it('surfaces a preview failure instead of an empty category list', async () => {
+    mockEndpoints({
+      preview: {
+        success: false,
+        message: 'Commit or tree deadbee not found in the repository',
+        ref: 'deadbee',
+        categories: [],
+      },
+    });
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument();
+    });
+    expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
+  });
+
+  it('surfaces a commit listing failure', async () => {
+    mockEndpoints({
+      commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] },
+    });
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Invalid access token')).toBeInTheDocument();
+    });
+  });
+
+  // A refused restore answers 200 with `success: false`, and two of the five
+  // refusals are ordinary conditions rather than errors — a restore already
+  // running, and a backup mid-flight. Rendering the result panel for those put a
+  // green tick and "reload so the restored data appears" above a message saying
+  // nothing had been restored, i.e. a failure that read as a success.
+  it('reports a backend refusal such as the backup/restore mutex', async () => {
+    server.use(
+      http.post('/api/v1/github-backup/restore', () =>
+        HttpResponse.json({
+          success: false,
+          message: 'A backup is currently running. Wait for it to finish before restoring.',
+          results: {},
+        })
+      )
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => {
+      expect(
+        screen.getByText('A backup is currently running. Wait for it to finish before restoring.')
+      ).toBeInTheDocument();
+    });
+
+    // Not the success panel: no reload hint, no "Reload now", and the form is
+    // still there so the user can retry once the backup finishes.
+    expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument();
+    expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled();
+  });
+
+  it('does not refresh the data caches when a restore was refused', async () => {
+    server.use(
+      http.post('/api/v1/github-backup/restore', () =>
+        HttpResponse.json({ success: false, message: 'A restore is already running', results: {} })
+      )
+    );
+    const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
+    await userEvent.click(checkboxes[1]);
+    await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
+    await waitFor(() => screen.getByText('Restore from backup?'));
+    const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
+    await userEvent.click(confirmButtons[confirmButtons.length - 1]);
+    await waitFor(() => screen.getByText('A restore is already running'));
+
+    const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
+    // Nothing was written, so nothing to re-read...
+    expect(keys).not.toContain(JSON.stringify(['spools']));
+    expect(keys).not.toContain(JSON.stringify(['archives']));
+    // ...but a failure past the commit resolve writes a "failed" log row, so the
+    // history is refreshed whatever the outcome.
+    expect(keys).toContain(JSON.stringify(['github-backup-logs']));
+    invalidate.mockRestore();
+  });
+
+  // A provider-side failure answers 200 with `success: false`; a rejected
+  // *request* throws in `request()`, leaving `data` undefined. Reading the
+  // message off `data` alone meant the second kind rendered an empty modal —
+  // picker holding only "Latest", every category greyed out, no explanation.
+  it('explains a rejected preview request instead of greying out every category', async () => {
+    server.use(
+      http.get('/api/v1/github-backup/restore/preview', () =>
+        HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 })
+      )
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Not authenticated')).toBeInTheDocument();
+    });
+    // The category list is replaced by the error, not rendered disabled.
+    expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
+  });
+
+  it('explains a rejected commit-list request', async () => {
+    server.use(
+      http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 }))
+    );
+    render(<GitHubRestoreModal onClose={vi.fn()} />);
+
+    // No detail in the body, so the generic string carries the message.
+    await waitFor(() => {
+      expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument();
+    });
+  });
+
+  it('closes via the close button', async () => {
+    const onClose = vi.fn();
+    render(<GitHubRestoreModal onClose={onClose} />);
+
+    await waitFor(() => screen.getByText('Restore from Git Backup'));
+    await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+
+    expect(onClose).toHaveBeenCalled();
+  });
+});

+ 69 - 0
frontend/src/api/client.ts

@@ -2820,12 +2820,68 @@ export interface GitHubBackupStatus {
   configured: boolean;
   enabled: boolean;
   is_running: boolean;
+  restore_running: boolean;
   progress: string | null;
   last_backup_at: string | null;
   last_backup_status: string | null;
   next_scheduled_run: string | null;
 }
 
+// Restore from a Git backup (#2656). Cloud profiles are absent deliberately —
+// the backup collector never writes them, so there is nothing to restore.
+export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives';
+
+export interface GitHubCommitInfo {
+  sha: string;
+  message: string;
+  author: string;
+  date: string;
+}
+
+export interface GitHubCommitListResponse {
+  success: boolean;
+  message: string;
+  branch: string;
+  commits: GitHubCommitInfo[];
+}
+
+export interface GitHubRestorePreviewCategory {
+  category: RestoreCategory;
+  available: boolean;
+  item_count: number;
+  detail: string | null;
+}
+
+export interface GitHubRestorePreview {
+  success: boolean;
+  message: string;
+  ref: string;
+  commit: GitHubCommitInfo | null;
+  metadata_version: string | null;
+  categories: GitHubRestorePreviewCategory[];
+}
+
+export interface GitHubRestoreRequest {
+  ref?: string;
+  categories: RestoreCategory[];
+  overwrite_existing?: boolean;
+}
+
+export interface GitHubRestoreCategoryResult {
+  restored: number;
+  skipped: number;
+  failed: number;
+  notes: string[];
+}
+
+export interface GitHubRestoreResponse {
+  success: boolean;
+  message: string;
+  log_id: number | null;
+  ref: string | null;
+  results: Record<string, GitHubRestoreCategoryResult>;
+}
+
 export interface LocalBackupStatus {
   enabled: boolean;
   schedule: string;
@@ -6684,6 +6740,19 @@ export const api = {
   clearGitHubBackupLogs: (keepLast: number = 10) =>
     request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
 
+  // Restore from a Git backup (#2656)
+  getGitHubBackupCommits: (limit: number = 20) =>
+    request<GitHubCommitListResponse>(`/github-backup/commits?limit=${limit}`),
+
+  getGitHubRestorePreview: (ref: string = 'HEAD') =>
+    request<GitHubRestorePreview>(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`),
+
+  restoreFromGitHub: (payload: GitHubRestoreRequest) =>
+    request<GitHubRestoreResponse>('/github-backup/restore', {
+      method: 'POST',
+      body: JSON.stringify(payload),
+    }),
+
   // Scheduled local backups
   getLocalBackupStatus: () =>
     request<LocalBackupStatus>('/local-backup/status'),

+ 17 - 0
frontend/src/components/GitHubBackupSettings.tsx

@@ -40,6 +40,7 @@ import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
+import { GitHubRestoreModal } from './GitHubRestoreModal';
 import { useToast } from '../contexts/ToastContext';
 import { formatRelativeTime, parseUTCDate } from '../utils/date';
 
@@ -158,6 +159,9 @@ export function GitHubBackupSettings() {
   const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
+  // Restore from the Git backup repository (#2656)
+  const [showGitRestore, setShowGitRestore] = useState(false);
+
   // Scheduled local backup state
   const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
   const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
@@ -951,6 +955,16 @@ export function GitHubBackupSettings() {
                           {testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
                           {t('backup.test')}
                         </Button>
+                        {/* Restore from the backup repo (#2656) */}
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={() => setShowGitRestore(true)}
+                          disabled={status.restore_running}
+                        >
+                          <RotateCcw className="w-4 h-4" />
+                          {t('backup.restoreFromGit.button')}
+                        </Button>
                       </>
                     )}
                   </>
@@ -1445,6 +1459,9 @@ export function GitHubBackupSettings() {
         </Card>
       </div>
 
+      {/* Restore from the Git backup repository (#2656) */}
+      {showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
+
       {/* Delete Backup Confirmation Modal */}
       {deleteConfirmFile && (
         <ConfirmModal

+ 431 - 0
frontend/src/components/GitHubRestoreModal.tsx

@@ -0,0 +1,431 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  Archive,
+  CheckCircle2,
+  Info,
+  Loader2,
+  Palette,
+  RotateCcw,
+  Settings as SettingsIcon,
+  Thermometer,
+  X,
+} from 'lucide-react';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { ConfirmModal } from './ConfirmModal';
+import { api, type RestoreCategory, type GitHubRestoreResponse } from '../api/client';
+
+interface GitHubRestoreModalProps {
+  onClose: () => void;
+}
+
+interface CategoryMeta {
+  id: RestoreCategory;
+  labelKey: string;
+  icon: React.ReactNode;
+}
+
+// Order mirrors the order the backend applies them in. Labels reuse the keys
+// the backup checkbox group already ships in all locales.
+const CATEGORIES: CategoryMeta[] = [
+  { id: 'archives', labelKey: 'backup.printArchives', icon: <Archive className="w-4 h-4" /> },
+  { id: 'spools', labelKey: 'backup.spoolInventory', icon: <Palette className="w-4 h-4" /> },
+  { id: 'settings', labelKey: 'backup.appSettings', icon: <SettingsIcon className="w-4 h-4" /> },
+  { id: 'kprofiles', labelKey: 'backup.kProfiles', icon: <Thermometer className="w-4 h-4" /> },
+];
+
+const CATEGORY_LABEL_KEYS: Record<string, string> = Object.fromEntries(
+  CATEGORIES.map((c) => [c.id, c.labelKey])
+);
+
+const LATEST = 'HEAD';
+
+export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+
+  const [selectedRef, setSelectedRef] = useState<string>(LATEST);
+  const [selected, setSelected] = useState<Record<string, boolean>>({});
+  const [overwriteExisting, setOverwriteExisting] = useState(false);
+  const [showConfirm, setShowConfirm] = useState(false);
+  const [result, setResult] = useState<GitHubRestoreResponse | null>(null);
+
+  const commitsQuery = useQuery({
+    queryKey: ['github-backup-commits'],
+    queryFn: () => api.getGitHubBackupCommits(20),
+  });
+
+  const previewQuery = useQuery({
+    queryKey: ['github-restore-preview', selectedRef],
+    queryFn: () => api.getGitHubRestorePreview(selectedRef),
+  });
+
+  // Restore the exact commit the preview described, not the ref that was asked
+  // for. They differ for the default "Latest backup" selection, which posts the
+  // symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing
+  // between preview and restore would silently restore a different commit than
+  // the one whose contents the user just approved.
+  const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef;
+
+  const restoreMutation = useMutation({
+    mutationFn: () =>
+      api.restoreFromGitHub({
+        ref: resolvedRef,
+        categories: CATEGORIES.filter((c) => selected[c.id]).map((c) => c.id),
+        overwrite_existing: overwriteExisting,
+      }),
+    onSuccess: (data) => {
+      setShowConfirm(false);
+      // The endpoint answers 200 for a refused or failed restore too, with
+      // `success: false` and an empty `results` — and two of those are ordinary
+      // conditions, not errors: another restore already running, and a backup
+      // being mid-flight. Rendering the result panel for them showed a green
+      // tick, no tally at all and a "reload so the restored data appears" hint
+      // above a message saying nothing had been restored. Only a real success
+      // gets the panel; a failure keeps the form and shows the red block below.
+      if (data.success) {
+        setResult(data);
+        // A restore rewrites rows these caches hold.
+        queryClient.invalidateQueries({ queryKey: ['spools'] });
+        queryClient.invalidateQueries({ queryKey: ['archives'] });
+        queryClient.invalidateQueries({ queryKey: ['settings'] });
+      }
+      // A failure that got as far as resolving the commit still writes a log row
+      // (status "failed"), so refresh the history and status either way.
+      queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
+      queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
+    },
+    onError: () => setShowConfirm(false),
+  });
+
+  const isRestoring = restoreMutation.isPending;
+
+  // Close on Escape, except while a restore is in flight.
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !isRestoring && !showConfirm) onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, isRestoring, showConfirm]);
+
+  // Interrupting a restore mid-flight can leave a partly-applied category.
+  useEffect(() => {
+    if (!isRestoring) return;
+    const handler = (e: BeforeUnloadEvent) => {
+      e.preventDefault();
+      e.returnValue = '';
+    };
+    window.addEventListener('beforeunload', handler);
+    return () => window.removeEventListener('beforeunload', handler);
+  }, [isRestoring]);
+
+  const availability = useMemo(() => {
+    const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
+    previewQuery.data?.categories?.forEach((c) => {
+      map[c.category] = { available: c.available, itemCount: c.item_count, detail: c.detail };
+    });
+    return map;
+  }, [previewQuery.data]);
+
+  // Selecting a category that isn't in the newly-picked commit would send a
+  // request the backend rejects, so drop those whenever the preview changes.
+  useEffect(() => {
+    if (!previewQuery.data) return;
+    setSelected((prev) => {
+      const next: Record<string, boolean> = {};
+      CATEGORIES.forEach((c) => {
+        next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available);
+      });
+      return next;
+    });
+  }, [previewQuery.data, availability]);
+
+  const selectedCount = CATEGORIES.filter((c) => selected[c.id]).length;
+  const commits = commitsQuery.data?.commits ?? [];
+
+  const formatCommitLabel = (sha: string, message: string, date: string) => {
+    const firstLine = (message || '').split('\n')[0];
+    const when = date ? new Date(date).toLocaleString() : '';
+    return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`;
+  };
+
+  // Two ways these can fail, and both have to reach the user. A provider-side
+  // failure (bad token, repo unreachable) answers 200 with `success: false` and
+  // a message. A rejected *request* — a 401/403 once the session expires with
+  // the modal open, a 500, the network dropping — throws in `request()`, so
+  // `data` is undefined: reading the message off `data` alone left the picker
+  // holding only "Latest" and every category greyed out by an empty availability
+  // map, with nothing on screen saying why.
+  const queryError = (query: { isError: boolean; error: unknown }) =>
+    query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null;
+
+  const previewError =
+    queryError(previewQuery) ??
+    (previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null);
+  const commitsError =
+    queryError(commitsQuery) ??
+    (commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null);
+
+  return (
+    <>
+      <div
+        className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+        onClick={isRestoring ? undefined : onClose}
+      >
+        <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+          <CardContent className="p-0">
+            {/* Header */}
+            <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+              <div className="flex items-center gap-3">
+                <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
+                  <RotateCcw className="w-5 h-5" />
+                </div>
+                <div>
+                  <h3 className="text-lg font-semibold text-white">{t('backup.restoreFromGit.title')}</h3>
+                  <p className="text-sm text-bambu-gray">{t('backup.restoreFromGit.subtitle')}</p>
+                </div>
+              </div>
+              <button
+                onClick={onClose}
+                disabled={isRestoring}
+                aria-label={t('common.close')}
+                className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+
+            {result ? (
+              /* Result summary */
+              <div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
+                <div className="flex items-start gap-2 text-sm">
+                  <CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                  <span className="text-white">{result.message}</span>
+                </div>
+                {Object.entries(result.results).map(([name, tally]) => (
+                  <div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+                    <div className="flex items-center justify-between">
+                      <span className="text-sm font-medium text-white">
+                        {CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name}
+                      </span>
+                      <span className="text-xs text-bambu-gray">
+                        {t('backup.restoreFromGit.tally', {
+                          restored: tally.restored,
+                          skipped: tally.skipped,
+                          failed: tally.failed,
+                        })}
+                      </span>
+                    </div>
+                    {tally.notes.length > 0 && (
+                      <ul className="mt-2 space-y-1">
+                        {tally.notes.map((note) => (
+                          <li key={note} className="text-xs text-bambu-gray flex items-start gap-1.5">
+                            <Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
+                            <span>{note}</span>
+                          </li>
+                        ))}
+                      </ul>
+                    )}
+                  </div>
+                ))}
+                <div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
+                  <p className="text-xs text-yellow-700 dark:text-yellow-200">
+                    {t('backup.restoreFromGit.reloadHint')}
+                  </p>
+                </div>
+              </div>
+            ) : (
+              <div className={`p-4 space-y-4 max-h-[400px] overflow-y-auto ${isRestoring ? 'opacity-50 pointer-events-none' : ''}`}>
+                {/* A restore that was refused or failed comes back here rather
+                    than to the result panel, so keep these above the fold. */}
+                {restoreMutation.isError && (
+                  <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+                    <p className="text-sm text-red-700 dark:text-red-400">
+                      {(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}
+                    </p>
+                  </div>
+                )}
+                {restoreMutation.data && !restoreMutation.data.success && (
+                  <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+                    <p className="text-sm text-red-700 dark:text-red-400">{restoreMutation.data.message}</p>
+                  </div>
+                )}
+
+                {/* Commit picker */}
+                <div>
+                  <label htmlFor="restore-commit" className="block text-sm font-medium text-white mb-1">
+                    {t('backup.restoreFromGit.commitLabel')}
+                  </label>
+                  <select
+                    id="restore-commit"
+                    value={selectedRef}
+                    onChange={(e) => {
+                      setSelectedRef(e.target.value);
+                      // Drop the previous attempt's failure banner: it refers to
+                      // the commit that was just switched away from. (A *result*
+                      // cannot be showing here — the summary replaces this form.)
+                      restoreMutation.reset();
+                    }}
+                    disabled={isRestoring || commitsQuery.isLoading}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                  >
+                    <option value={LATEST}>{t('backup.restoreFromGit.latestCommit')}</option>
+                    {commits.map((c) => (
+                      <option key={c.sha} value={c.sha}>
+                        {formatCommitLabel(c.sha, c.message, c.date)}
+                      </option>
+                    ))}
+                  </select>
+                  {commitsError && <p className="mt-1 text-xs text-red-500 dark:text-red-400">{commitsError}</p>}
+                </div>
+
+                {/* Category selection */}
+                <div>
+                  <p className="text-sm font-medium text-white mb-2">{t('backup.restoreFromGit.categoriesLabel')}</p>
+                  {previewQuery.isLoading ? (
+                    <div className="flex items-center gap-2 text-sm text-bambu-gray p-3">
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      {t('backup.restoreFromGit.inspecting')}
+                    </div>
+                  ) : previewError ? (
+                    <div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
+                      <p className="text-sm text-red-700 dark:text-red-400">{previewError}</p>
+                    </div>
+                  ) : (
+                    <div className="space-y-2">
+                      {CATEGORIES.map((category) => {
+                        const info = availability[category.id];
+                        const isAvailable = Boolean(info?.available);
+                        const isChecked = Boolean(selected[category.id]) && isAvailable;
+                        return (
+                          <label
+                            key={category.id}
+                            className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
+                              isAvailable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
+                            } ${
+                              isChecked
+                                ? 'bg-bambu-green/10 border border-bambu-green/30'
+                                : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
+                            }`}
+                          >
+                            <input
+                              type="checkbox"
+                              checked={isChecked}
+                              disabled={!isAvailable || isRestoring}
+                              onChange={() =>
+                                setSelected((prev) => ({ ...prev, [category.id]: !prev[category.id] }))
+                              }
+                              className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
+                            />
+                            <div className={isChecked ? 'text-bambu-green' : 'text-bambu-gray'}>{category.icon}</div>
+                            <div className="flex-1">
+                              <div className="text-white text-sm font-medium">
+                                {t(category.labelKey)}
+                                {isAvailable && info?.itemCount ? (
+                                  <span className="ml-2 text-xs text-bambu-gray">
+                                    {t('backup.restoreFromGit.itemCount', { count: info.itemCount })}
+                                  </span>
+                                ) : null}
+                              </div>
+                              {info?.detail && <div className="text-xs text-bambu-gray">{info.detail}</div>}
+                            </div>
+                          </label>
+                        );
+                      })}
+                    </div>
+                  )}
+                </div>
+
+                {/* Overwrite toggle */}
+                <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
+                  <div className="flex items-center justify-between gap-3">
+                    <div>
+                      <p className="text-sm font-medium text-white">{t('backup.restoreFromGit.overwriteLabel')}</p>
+                      <p className="text-xs text-bambu-gray">
+                        {overwriteExisting
+                          ? t('backup.restoreFromGit.overwriteOn')
+                          : t('backup.restoreFromGit.overwriteOff')}
+                      </p>
+                    </div>
+                    <Toggle checked={overwriteExisting} onChange={setOverwriteExisting} disabled={isRestoring} />
+                  </div>
+                </div>
+
+              </div>
+            )}
+
+            {/* Footer */}
+            <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
+              {result ? (
+                <>
+                  <span />
+                  <div className="flex gap-3">
+                    <Button variant="secondary" onClick={onClose}>
+                      {t('common.close')}
+                    </Button>
+                    <Button
+                      onClick={() => window.location.reload()}
+                      className="bg-bambu-green hover:bg-bambu-green-dark"
+                    >
+                      {t('backup.reloadNow')}
+                    </Button>
+                  </div>
+                </>
+              ) : (
+                <>
+                  <span className="text-sm text-bambu-gray">
+                    {t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
+                  </span>
+                  <div className="flex gap-3">
+                    <Button variant="secondary" onClick={onClose} disabled={isRestoring}>
+                      {t('common.cancel')}
+                    </Button>
+                    <Button
+                      onClick={() => setShowConfirm(true)}
+                      disabled={selectedCount === 0 || isRestoring}
+                      className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+                    >
+                      {isRestoring ? (
+                        <>
+                          <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                          {t('backup.restoreFromGit.restoring')}
+                        </>
+                      ) : (
+                        <>
+                          <RotateCcw className="w-4 h-4 mr-2" />
+                          {t('backup.restore')}
+                        </>
+                      )}
+                    </Button>
+                  </div>
+                </>
+              )}
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {showConfirm && (
+        <ConfirmModal
+          variant="danger"
+          overlayZIndex="z-[110]"
+          title={t('backup.restoreFromGit.confirmTitle')}
+          message={
+            overwriteExisting
+              ? t('backup.restoreFromGit.confirmMessageOverwrite')
+              : t('backup.restoreFromGit.confirmMessage')
+          }
+          confirmText={t('backup.restore')}
+          isLoading={isRestoring}
+          loadingText={t('backup.restoreFromGit.restoring')}
+          onConfirm={() => restoreMutation.mutate()}
+          onCancel={() => setShowConfirm(false)}
+        />
+      )}
+    </>
+  );
+}

+ 24 - 0
frontend/src/i18n/locales/de.ts

@@ -4873,6 +4873,30 @@ export default {
     clearedLogs: '{{count}} Protokolle gelöscht',
     failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Wiederherstellen',
+      title: 'Aus Git-Backup wiederherstellen',
+      subtitle: 'Commit auswählen und festlegen, was wiederhergestellt wird',
+      commitLabel: 'Backup-Commit',
+      latestCommit: 'Neuestes Backup (Branch-Spitze)',
+      categoriesLabel: 'Was wiederherstellen',
+      inspecting: 'Backup-Inhalt wird gelesen...',
+      itemCount: '{{count}} im Backup',
+      overwriteLabel: 'Vorhandene Einträge überschreiben',
+      overwriteOn: 'Vorhandene Einträge werden aus dem Backup aktualisiert.',
+      overwriteOff: 'Es werden nur fehlende Einträge ergänzt; vorhandene bleiben unverändert.',
+      selectedCount: '{{count}} ausgewählt',
+      restoring: 'Wird wiederhergestellt...',
+      confirmTitle: 'Aus Backup wiederherstellen?',
+      confirmMessage: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt. Fehlende Einträge werden ergänzt, vorhandene bleiben unverändert.',
+      confirmMessageOverwrite: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt und lokal vorhandene Einträge überschrieben. Dies kann nicht rückgängig gemacht werden.',
+      tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
+      reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
+      failed: 'Wiederherstellung fehlgeschlagen.',
+      loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
+    },
+
     // History
     history: 'Verlauf',
     clear: 'Löschen',

+ 24 - 0
frontend/src/i18n/locales/en.ts

@@ -4916,6 +4916,30 @@ export default {
     clearedLogs: 'Cleared {{count}} logs',
     failedToClearLogs: 'Failed to clear logs: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restore',
+      title: 'Restore from Git Backup',
+      subtitle: 'Pick a commit and choose what to restore',
+      commitLabel: 'Backup commit',
+      latestCommit: 'Latest backup (branch tip)',
+      categoriesLabel: 'What to restore',
+      inspecting: 'Reading backup contents...',
+      itemCount: '{{count}} in backup',
+      overwriteLabel: 'Overwrite existing entries',
+      overwriteOn: 'Existing entries will be updated from the backup.',
+      overwriteOff: 'Only missing entries are added; existing ones are left untouched.',
+      selectedCount: '{{count}} selected',
+      restoring: 'Restoring...',
+      confirmTitle: 'Restore from backup?',
+      confirmMessage: 'The selected categories will be restored from this commit. Missing entries are added; existing entries stay as they are.',
+      confirmMessageOverwrite: 'The selected categories will be restored from this commit, overwriting entries that already exist locally. This cannot be undone.',
+      tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
+      reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
+      failed: 'Restore failed.',
+      loadFailed: 'Could not read the backup repository.',
+    },
+
     // History
     history: 'History',
     clear: 'Clear',

+ 24 - 0
frontend/src/i18n/locales/es.ts

@@ -4881,6 +4881,30 @@ export default {
     clearedLogs: 'Se borraron {{count}} registros',
     failedToClearLogs: 'Error al borrar los registros: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restaurar',
+      title: 'Restaurar desde copia de Git',
+      subtitle: 'Elige un commit y qué se debe restaurar',
+      commitLabel: 'Commit de la copia',
+      latestCommit: 'Última copia (punta de la rama)',
+      categoriesLabel: 'Qué restaurar',
+      inspecting: 'Leyendo el contenido de la copia...',
+      itemCount: '{{count}} en la copia',
+      overwriteLabel: 'Sobrescribir entradas existentes',
+      overwriteOn: 'Las entradas existentes se actualizarán desde la copia.',
+      overwriteOff: 'Solo se añaden las entradas que falten; las existentes no se modifican.',
+      selectedCount: '{{count}} seleccionados',
+      restoring: 'Restaurando...',
+      confirmTitle: '¿Restaurar desde la copia?',
+      confirmMessage: 'Las categorías seleccionadas se restaurarán desde este commit. Se añaden las entradas que falten y las existentes se mantienen igual.',
+      confirmMessageOverwrite: 'Las categorías seleccionadas se restaurarán desde este commit y se sobrescribirán las entradas que ya existan localmente. Esto no se puede deshacer.',
+      tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
+      reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
+      failed: 'La restauración ha fallado.',
+      loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
+    },
+
     // History
     history: 'Historial',
     clear: 'Borrar',

+ 24 - 0
frontend/src/i18n/locales/fr.ts

@@ -4862,6 +4862,30 @@ export default {
     clearedLogs: '{{count}} journaux supprimés',
     failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restaurer',
+      title: 'Restaurer depuis la sauvegarde Git',
+      subtitle: 'Choisissez un commit et ce qui doit être restauré',
+      commitLabel: 'Commit de sauvegarde',
+      latestCommit: 'Dernière sauvegarde (tête de branche)',
+      categoriesLabel: 'Éléments à restaurer',
+      inspecting: 'Lecture du contenu de la sauvegarde...',
+      itemCount: '{{count}} dans la sauvegarde',
+      overwriteLabel: 'Écraser les entrées existantes',
+      overwriteOn: 'Les entrées existantes seront mises à jour depuis la sauvegarde.',
+      overwriteOff: 'Seules les entrées manquantes sont ajoutées ; les existantes ne sont pas modifiées.',
+      selectedCount: '{{count}} sélectionné(s)',
+      restoring: 'Restauration...',
+      confirmTitle: 'Restaurer depuis la sauvegarde ?',
+      confirmMessage: 'Les catégories sélectionnées seront restaurées depuis ce commit. Les entrées manquantes sont ajoutées, les existantes restent inchangées.',
+      confirmMessageOverwrite: 'Les catégories sélectionnées seront restaurées depuis ce commit et les entrées déjà présentes localement seront écrasées. Cette action est irréversible.',
+      tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
+      reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
+      failed: 'Échec de la restauration.',
+      loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
+    },
+
     // History
     history: 'Historique',
     clear: 'Effacer',

+ 24 - 0
frontend/src/i18n/locales/it.ts

@@ -4861,6 +4861,30 @@ export default {
     clearedLogs: '{{count}} log eliminati',
     failedToClearLogs: 'Eliminazione log fallita: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Ripristina',
+      title: 'Ripristina dal backup Git',
+      subtitle: 'Scegli un commit e cosa ripristinare',
+      commitLabel: 'Commit del backup',
+      latestCommit: 'Ultimo backup (punta del branch)',
+      categoriesLabel: 'Cosa ripristinare',
+      inspecting: 'Lettura del contenuto del backup...',
+      itemCount: '{{count}} nel backup',
+      overwriteLabel: 'Sovrascrivi le voci esistenti',
+      overwriteOn: 'Le voci esistenti verranno aggiornate dal backup.',
+      overwriteOff: 'Vengono aggiunte solo le voci mancanti; quelle esistenti restano invariate.',
+      selectedCount: '{{count}} selezionati',
+      restoring: 'Ripristino in corso...',
+      confirmTitle: 'Ripristinare dal backup?',
+      confirmMessage: 'Le categorie selezionate verranno ripristinate da questo commit. Le voci mancanti vengono aggiunte, quelle esistenti restano invariate.',
+      confirmMessageOverwrite: 'Le categorie selezionate verranno ripristinate da questo commit sovrascrivendo le voci già presenti in locale. Operazione non annullabile.',
+      tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
+      reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
+      failed: 'Ripristino non riuscito.',
+      loadFailed: 'Impossibile leggere il repository di backup.',
+    },
+
     // History
     history: 'Cronologia',
     clear: 'Cancella',

+ 24 - 0
frontend/src/i18n/locales/ja.ts

@@ -4873,6 +4873,30 @@ export default {
     clearedLogs: '{{count}}件のログを削除しました',
     failedToClearLogs: 'ログの削除に失敗しました: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: '復元',
+      title: 'Git バックアップから復元',
+      subtitle: 'コミットと復元する項目を選択します',
+      commitLabel: 'バックアップのコミット',
+      latestCommit: '最新のバックアップ (ブランチ先端)',
+      categoriesLabel: '復元する項目',
+      inspecting: 'バックアップの内容を読み込んでいます...',
+      itemCount: 'バックアップ内に {{count}} 件',
+      overwriteLabel: '既存のエントリを上書きする',
+      overwriteOn: '既存のエントリはバックアップの内容で更新されます。',
+      overwriteOff: '不足しているエントリのみ追加され、既存のものは変更されません。',
+      selectedCount: '{{count}} 件選択中',
+      restoring: '復元しています...',
+      confirmTitle: 'バックアップから復元しますか?',
+      confirmMessage: '選択したカテゴリをこのコミットから復元します。不足しているエントリが追加され、既存のエントリはそのまま残ります。',
+      confirmMessageOverwrite: '選択したカテゴリをこのコミットから復元し、ローカルに既存のエントリを上書きします。この操作は取り消せません。',
+      tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
+      reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
+      failed: '復元に失敗しました。',
+      loadFailed: 'バックアップリポジトリを読み取れませんでした。',
+    },
+
     // History
     history: '履歴',
     clear: 'クリア',

+ 24 - 0
frontend/src/i18n/locales/ko.ts

@@ -4637,6 +4637,30 @@ export default {
     backupFailed2: '백업 실패: {{message}}',
     clearedLogs: '{{count}}개 로그 초기화됨',
     failedToClearLogs: '로그 초기화 실패: {{message}}',
+
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: '복원',
+      title: 'Git 백업에서 복원',
+      subtitle: '커밋과 복원할 항목을 선택하세요',
+      commitLabel: '백업 커밋',
+      latestCommit: '최신 백업 (브랜치 최신 커밋)',
+      categoriesLabel: '복원할 항목',
+      inspecting: '백업 내용을 읽고 있습니다...',
+      itemCount: '백업에 {{count}}개',
+      overwriteLabel: '기존 항목 덮어쓰기',
+      overwriteOn: '기존 항목이 백업 내용으로 업데이트됩니다.',
+      overwriteOff: '없는 항목만 추가되고 기존 항목은 그대로 유지됩니다.',
+      selectedCount: '{{count}}개 선택됨',
+      restoring: '복원 중...',
+      confirmTitle: '백업에서 복원하시겠습니까?',
+      confirmMessage: '선택한 항목을 이 커밋에서 복원합니다. 없는 항목은 추가되고 기존 항목은 그대로 유지됩니다.',
+      confirmMessageOverwrite: '선택한 항목을 이 커밋에서 복원하고 로컬에 이미 있는 항목을 덮어씁니다. 이 작업은 취소할 수 없습니다.',
+      tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
+      reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
+      failed: '복원에 실패했습니다.',
+      loadFailed: '백업 저장소를 읽을 수 없습니다.',
+    },
     history: '기록',
     clear: '초기화',
     date: '날짜',

+ 24 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4861,6 +4861,30 @@ export default {
     clearedLogs: '{{count}} logs removidos',
     failedToClearLogs: 'Falha ao limpar logs: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Restaurar',
+      title: 'Restaurar do backup Git',
+      subtitle: 'Escolha um commit e o que deve ser restaurado',
+      commitLabel: 'Commit do backup',
+      latestCommit: 'Backup mais recente (ponta do branch)',
+      categoriesLabel: 'O que restaurar',
+      inspecting: 'Lendo o conteúdo do backup...',
+      itemCount: '{{count}} no backup',
+      overwriteLabel: 'Sobrescrever entradas existentes',
+      overwriteOn: 'As entradas existentes serão atualizadas a partir do backup.',
+      overwriteOff: 'Apenas as entradas ausentes são adicionadas; as existentes permanecem intactas.',
+      selectedCount: '{{count}} selecionados',
+      restoring: 'Restaurando...',
+      confirmTitle: 'Restaurar do backup?',
+      confirmMessage: 'As categorias selecionadas serão restauradas deste commit. As entradas ausentes são adicionadas e as existentes permanecem como estão.',
+      confirmMessageOverwrite: 'As categorias selecionadas serão restauradas deste commit, sobrescrevendo as entradas que já existem localmente. Não é possível desfazer.',
+      tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
+      reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
+      failed: 'Falha na restauração.',
+      loadFailed: 'Não foi possível ler o repositório de backup.',
+    },
+
     // History
     history: 'Histórico',
     clear: 'Limpar',

+ 24 - 0
frontend/src/i18n/locales/ru.ts

@@ -4629,6 +4629,30 @@ export default {
     backupFailed2: "Ошибка резервного копирования: {{message}}",
     clearedLogs: "Очищено записей журнала: {{count}}",
     failedToClearLogs: "Не удалось очистить журнал: {{message}}",
+
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Восстановить',
+      title: 'Восстановление из резервной копии Git',
+      subtitle: 'Выберите коммит и данные для восстановления',
+      commitLabel: 'Коммит резервной копии',
+      latestCommit: 'Последняя резервная копия (вершина ветки)',
+      categoriesLabel: 'Что восстановить',
+      inspecting: 'Чтение содержимого резервной копии...',
+      itemCount: '{{count}} в резервной копии',
+      overwriteLabel: 'Перезаписывать существующие записи',
+      overwriteOn: 'Существующие записи будут обновлены из резервной копии.',
+      overwriteOff: 'Добавляются только отсутствующие записи, существующие не изменяются.',
+      selectedCount: 'Выбрано: {{count}}',
+      restoring: 'Восстановление...',
+      confirmTitle: 'Восстановить из резервной копии?',
+      confirmMessage: 'Выбранные категории будут восстановлены из этого коммита. Отсутствующие записи будут добавлены, существующие останутся без изменений.',
+      confirmMessageOverwrite: 'Выбранные категории будут восстановлены из этого коммита с перезаписью уже существующих локальных записей. Отменить это действие нельзя.',
+      tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
+      reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
+      failed: 'Не удалось выполнить восстановление.',
+      loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
+    },
     history: "История",
     clear: "Очистить",
     date: "Дата",

+ 24 - 0
frontend/src/i18n/locales/tr.ts

@@ -4851,6 +4851,30 @@ export default {
     clearedLogs: '{{count}} günlük temizlendi',
     failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: 'Geri Yükle',
+      title: 'Git yedeğinden geri yükle',
+      subtitle: 'Bir commit ve geri yüklenecek verileri seçin',
+      commitLabel: 'Yedek commit\'i',
+      latestCommit: 'En son yedek (dal ucu)',
+      categoriesLabel: 'Neler geri yüklenecek',
+      inspecting: 'Yedek içeriği okunuyor...',
+      itemCount: 'yedekte {{count}} kayıt',
+      overwriteLabel: 'Mevcut kayıtların üzerine yaz',
+      overwriteOn: 'Mevcut kayıtlar yedekten güncellenecek.',
+      overwriteOff: 'Yalnızca eksik kayıtlar eklenir; mevcut olanlara dokunulmaz.',
+      selectedCount: '{{count}} seçildi',
+      restoring: 'Geri yükleniyor...',
+      confirmTitle: 'Yedekten geri yüklensin mi?',
+      confirmMessage: 'Seçilen kategoriler bu commit\'ten geri yüklenecek. Eksik kayıtlar eklenir, mevcut kayıtlar olduğu gibi kalır.',
+      confirmMessageOverwrite: 'Seçilen kategoriler bu commit\'ten geri yüklenecek ve yerelde bulunan kayıtların üzerine yazılacak. Bu işlem geri alınamaz.',
+      tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız',
+      reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
+      failed: 'Geri yükleme başarısız oldu.',
+      loadFailed: 'Yedek deposu okunamadı.',
+    },
+
     history: 'Geçmiş',
     clear: 'Temizle',
     date: 'Tarih',

+ 24 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4861,6 +4861,30 @@ export default {
     clearedLogs: '已清除 {{count}} 条日志',
     failedToClearLogs: '清除日志失败:{{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: '恢复',
+      title: '从 Git 备份恢复',
+      subtitle: '选择提交以及要恢复的内容',
+      commitLabel: '备份提交',
+      latestCommit: '最新备份(分支最新提交)',
+      categoriesLabel: '恢复内容',
+      inspecting: '正在读取备份内容...',
+      itemCount: '备份中有 {{count}} 项',
+      overwriteLabel: '覆盖已有条目',
+      overwriteOn: '已有条目将根据备份内容更新。',
+      overwriteOff: '仅添加缺失的条目,已有条目保持不变。',
+      selectedCount: '已选择 {{count}} 项',
+      restoring: '正在恢复...',
+      confirmTitle: '要从备份恢复吗?',
+      confirmMessage: '将从此提交恢复所选类别。缺失的条目会被添加,已有条目保持不变。',
+      confirmMessageOverwrite: '将从此提交恢复所选类别,并覆盖本地已存在的条目。此操作无法撤销。',
+      tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
+      reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
+      failed: '恢复失败。',
+      loadFailed: '无法读取备份仓库。',
+    },
+
     // History
     history: '历史记录',
     clear: '清除',

+ 24 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4861,6 +4861,30 @@ export default {
     clearedLogs: '已清除 {{count}} 條日誌',
     failedToClearLogs: '清除日誌失敗:{{message}}',
 
+    // Restore from Git backup (#2656)
+    restoreFromGit: {
+      button: '還原',
+      title: '從 Git 備份還原',
+      subtitle: '選擇提交以及要還原的項目',
+      commitLabel: '備份提交',
+      latestCommit: '最新備份(分支最新提交)',
+      categoriesLabel: '還原項目',
+      inspecting: '正在讀取備份內容...',
+      itemCount: '備份中有 {{count}} 筆',
+      overwriteLabel: '覆寫既有項目',
+      overwriteOn: '既有項目將依備份內容更新。',
+      overwriteOff: '僅新增缺少的項目,既有項目保持不變。',
+      selectedCount: '已選擇 {{count}} 筆',
+      restoring: '正在還原...',
+      confirmTitle: '要從備份還原嗎?',
+      confirmMessage: '將從此提交還原所選類別。缺少的項目會被新增,既有項目保持不變。',
+      confirmMessageOverwrite: '將從此提交還原所選類別,並覆寫本機已存在的項目。此操作無法復原。',
+      tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
+      reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
+      failed: '還原失敗。',
+      loadFailed: '無法讀取備份儲存庫。',
+    },
+
     // History
     history: '歷史紀錄',
     clear: '清除',