diff --git a/picard/debug_opts.py b/picard/debug_opts.py index ec5a4c9ae3..25da53848e 100644 --- a/picard/debug_opts.py +++ b/picard/debug_opts.py @@ -83,3 +83,4 @@ class DebugOpt(DebugOptEnum): PLUGIN_FULLPATH = 1, N_('Plugin Fullpath'), N_('Log plugin full paths') WS_POST = 2, N_('Web Service Post Data'), N_('Log data of web service post requests') WS_REPLIES = 3, N_('Web Service Replies'), N_('Log content of web service replies') + GIT_BACKEND = 4, N_('Git Backend'), N_('Log git backend method calls') diff --git a/picard/plugin3/__init__.py b/picard/plugin3/__init__.py index aeafaf0339..464b130689 100644 --- a/picard/plugin3/__init__.py +++ b/picard/plugin3/__init__.py @@ -2,7 +2,7 @@ # # Picard, the next-generation MusicBrainz tagger # -# Copyright (C) 2024 Philipp Wolfer +# Copyright (C) 2025 Laurent Monin, Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -19,6 +19,18 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. from picard.plugin3.api import PluginApi +from picard.plugin3.git_backend import ( + GitBackendError, + GitCommitError, + GitReferenceError, + GitRepositoryError, +) -__all__ = ['PluginApi'] +__all__ = [ + 'PluginApi', + 'GitBackendError', + 'GitRepositoryError', + 'GitReferenceError', + 'GitCommitError', +] diff --git a/picard/plugin3/cli.py b/picard/plugin3/cli.py index b265d2b0d5..41d82f3c6a 100644 --- a/picard/plugin3/cli.py +++ b/picard/plugin3/cli.py @@ -1202,7 +1202,7 @@ def _cmd_clean_config(self, plugin_identifier): config.endGroup() if not has_config: - self._out.error(f'No saved options found for "{display_name}"') + self._out.print(f'No saved options found for "{display_name}"') # Show orphaned configs orphaned = self._manager.get_orphaned_plugin_configs() @@ -1213,7 +1213,7 @@ def _cmd_clean_config(self, plugin_identifier): self._out.print(f' • {self._out.d_uuid(uuid)}') self._out.nl() self._out.print(f'Clean with: {self._out.d_command("picard plugins --clean-config ")}') - return ExitCode.NOT_FOUND + return ExitCode.SUCCESS if not yes: if not self._out.yesno(f'Delete saved options for "{display_name}"?'): diff --git a/picard/plugin3/git_backend.py b/picard/plugin3/git_backend.py new file mode 100644 index 0000000000..5d747da4a3 --- /dev/null +++ b/picard/plugin3/git_backend.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2025 Philipp Wolfer, Laurent Monin +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Git backend abstraction layer.""" + +from abc import ( + ABC, + abstractmethod, +) +from enum import Enum +from pathlib import Path +from typing import ( + Any, + Dict, + List, + Optional, +) + +from picard import log +from picard.debug_opts import DebugOpt + + +class GitBackendError(Exception): + """Base exception for git backend operations""" + + +class GitRepositoryError(GitBackendError): + """Exception for repository-related errors""" + + +class GitReferenceError(GitBackendError): + """Exception for reference-related errors""" + + +class GitCommitError(GitBackendError): + """Exception for commit-related errors""" + + +class GitObjectType(Enum): + COMMIT = "commit" + TAG = "tag" + + +class GitStatusFlag(Enum): + CURRENT = 0 + IGNORED = 1 + MODIFIED = 2 + + +class GitResetMode(Enum): + HARD = "hard" + + +class GitCredentialType(Enum): + SSH_KEY = 1 + USERPASS = 2 + + +class GitRef: + def __init__(self, name: str, target: str = None): + self.name = name + self.target = target + + +class GitObject: + def __init__(self, id: str, obj_type: GitObjectType): + self.id = id + self.type = obj_type + + +class GitRemoteCallbacks: + """Abstract remote callbacks for authentication""" + + +def _log_git_call(method_name: str, *args, **kwargs): + """Log git backend method calls if debug option enabled""" + if DebugOpt.GIT_BACKEND.enabled: + args_str = ', '.join(str(arg)[:100] for arg in args) # Truncate long args + kwargs_str = ', '.join(f'{k}={str(v)[:50]}' for k, v in kwargs.items()) + all_args = ', '.join(filter(None, [args_str, kwargs_str])) + log.debug("Git backend call: %s(%s)", method_name, all_args) + + +class GitRepository(ABC): + """Abstract interface for repository operations""" + + @abstractmethod + def get_status(self) -> Dict[str, GitStatusFlag]: + """Get working directory status""" + + @abstractmethod + def get_head_target(self) -> str: + """Get HEAD commit ID""" + + @abstractmethod + def is_head_detached(self) -> bool: + """Check if HEAD is detached""" + + @abstractmethod + def get_head_shorthand(self) -> str: + """Get current branch name or short commit""" + + @abstractmethod + def get_head_name(self) -> str: + """Get HEAD reference name""" + + @abstractmethod + def revparse_single(self, ref: str) -> GitObject: + """Resolve reference to object""" + + @abstractmethod + def peel_to_commit(self, obj: GitObject) -> GitObject: + """Peel tag to underlying commit""" + + @abstractmethod + def reset(self, commit_id: str, mode: GitResetMode): + """Reset repository to commit""" + + @abstractmethod + def checkout_tree(self, obj: GitObject): + """Checkout tree object""" + + @abstractmethod + def set_head(self, target: str): + """Set HEAD to target""" + + @abstractmethod + def list_references(self) -> List[str]: + """List all references""" + + @abstractmethod + def get_references(self) -> List[str]: + """Get list of reference names""" + + @abstractmethod + def get_remotes(self) -> List[Any]: + """Get remotes list""" + + @abstractmethod + def get_remote(self, name: str) -> Any: + """Get specific remote by name""" + + @abstractmethod + def create_remote(self, name: str, url: str) -> Any: + """Create remote""" + + @abstractmethod + def get_branches(self) -> Any: + """Get branches object""" + + @abstractmethod + def get_commit_date(self, commit_id: str) -> int: + """Get commit timestamp for given commit ID""" + + @abstractmethod + def fetch_remote(self, remote, refspec: str = None, callbacks=None): + """Fetch from remote with optional refspec""" + + @abstractmethod + def free(self): + """Free repository resources""" + + +class GitBackend(ABC): + """Abstract interface for git operations""" + + @abstractmethod + def create_repository(self, path: Path) -> GitRepository: + """Open existing repository""" + + @abstractmethod + def init_repository(self, path: Path, bare: bool = False) -> GitRepository: + """Initialize new repository""" + + @abstractmethod + def create_commit( + self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "test@example.com" + ) -> str: + """Create a commit with all staged files""" + + @abstractmethod + def create_tag( + self, + repo: GitRepository, + tag_name: str, + commit_id: str, + message: str = "", + author_name: str = "Test", + author_email: str = "test@example.com", + ): + """Create a tag pointing to a commit""" + + @abstractmethod + def create_branch(self, repo: GitRepository, branch_name: str, commit_id: str): + """Create a branch pointing to a commit""" + + @abstractmethod + def add_and_commit_files( + self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "test@example.com" + ) -> str: + """Add all files and create commit""" + + @abstractmethod + def reset_hard(self, repo: GitRepository, commit_id: str): + """Reset repository to commit (hard reset)""" + + @abstractmethod + def create_reference(self, repo: GitRepository, ref_name: str, commit_id: str): + """Create a reference pointing to commit""" + + @abstractmethod + def set_head_detached(self, repo: GitRepository, commit_id: str): + """Set HEAD to detached state at commit""" + + @abstractmethod + def clone_repository(self, url: str, path: Path, **options) -> GitRepository: + """Clone repository from URL""" + + @abstractmethod + def fetch_remote_refs(self, url: str, **options) -> Optional[List[GitRef]]: + """Fetch remote refs without cloning""" + + @abstractmethod + def create_remote_callbacks(self) -> GitRemoteCallbacks: + """Create remote callbacks for authentication""" diff --git a/picard/plugin3/git_factory.py b/picard/plugin3/git_factory.py new file mode 100644 index 0000000000..4217f5562b --- /dev/null +++ b/picard/plugin3/git_factory.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2025 Philipp Wolfer, Laurent Monin +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Git backend factory.""" + +from picard.plugin3.git_backend import GitBackend + + +def get_git_backend() -> GitBackend: + """Get the configured git backend. + + Currently only supports pygit2 backend. + Future backends (CLI, dulwich) can be added here. + """ + from picard.plugin3.pygit2_backend import Pygit2Backend + + return Pygit2Backend() + + +def has_git_backend() -> bool: + """Check if a git backend is available.""" + from picard.plugin3.pygit2_backend import HAS_PYGIT2 + + return HAS_PYGIT2 + + +# Global backend instance +_git_backend = None + + +def git_backend() -> GitBackend: + """Get singleton git backend instance""" + global _git_backend + if _git_backend is None: + _git_backend = get_git_backend() + return _git_backend + + +def _reset_git_backend(): + """Reset the global git backend instance. Used for testing.""" + global _git_backend + _git_backend = None diff --git a/picard/plugin3/git_ops.py b/picard/plugin3/git_ops.py index cda9739ec3..ae7e0722dd 100644 --- a/picard/plugin3/git_ops.py +++ b/picard/plugin3/git_ops.py @@ -23,9 +23,13 @@ import os from pathlib import Path import shutil -import tempfile from picard import log +from picard.plugin3.git_backend import ( + GitBackendError, + GitStatusFlag, +) +from picard.plugin3.git_factory import git_backend def clean_python_cache(directory): @@ -60,15 +64,14 @@ def check_dirty_working_dir(path: Path): Raises: Exception: If path is not a git repository """ - import pygit2 - - repo = pygit2.Repository(str(path)) - status = repo.status() + backend = git_backend() + repo = backend.create_repository(path) + status = repo.get_status() # Check for any changes (modified, added, deleted, renamed, etc.) modified_files = [] - for filepath, flags in status.items(): - if flags != pygit2.GIT_STATUS_CURRENT and flags != pygit2.GIT_STATUS_IGNORED: + for filepath, flag in status.items(): + if flag not in (GitStatusFlag.CURRENT, GitStatusFlag.IGNORED): # Ignore Python cache files if ( filepath.endswith(('.pyc', '.pyo')) @@ -89,26 +92,10 @@ def fetch_remote_refs(url, use_callbacks=True): use_callbacks: Whether to use GitRemoteCallbacks for authentication Returns: - list: Remote refs from pygit2, or None on error + list: Remote refs, or None on error """ - import pygit2 - - try: - with tempfile.TemporaryDirectory() as tmpdir: - repo = pygit2.init_repository(tmpdir, bare=True) - remote = repo.remotes.create('origin', url) - - if use_callbacks: - from picard.plugin3.plugin import GitRemoteCallbacks - - callbacks = GitRemoteCallbacks() - return remote.list_heads(callbacks=callbacks) - else: - return remote.list_heads() - - except Exception as e: - log.warning('Failed to fetch remote refs from %s: %s', url, e) - return None + backend = git_backend() + return backend.fetch_remote_refs(url, use_callbacks=use_callbacks) @staticmethod def validate_ref(url, ref, uuid=None, registry=None): @@ -188,30 +175,28 @@ def check_ref_type(repo_path, ref=None): Returns: tuple: (ref_type, ref_name) where ref_type is 'commit', 'tag', 'branch', or None """ - import pygit2 - try: - repo = pygit2.Repository(str(repo_path)) + backend = git_backend() + repo = backend.create_repository(repo_path) + references = repo.get_references() if ref: # Check if ref exists in standard locations first - # This handles both lightweight and annotated tags - if f'refs/tags/{ref}' in repo.references: + if f'refs/tags/{ref}' in references: return 'tag', ref - if f'refs/heads/{ref}' in repo.references: + if f'refs/heads/{ref}' in references: return 'branch', ref - if f'refs/remotes/origin/{ref}' in repo.references: + if f'refs/remotes/origin/{ref}' in references: return 'branch', ref # Not found in standard refs, try to resolve it try: obj = repo.revparse_single(ref) - # It resolves. Check object type. - # Note: lightweight tags resolve to commits, but we already checked refs/tags above - if obj.type == pygit2.GIT_OBJECT_COMMIT: + from picard.plugin3.git_backend import GitObjectType + + if obj.type == GitObjectType.COMMIT: return 'commit', ref - elif obj.type == pygit2.GIT_OBJECT_TAG: - # Annotated tag object + elif obj.type == GitObjectType.TAG: return 'tag', ref else: return None, ref @@ -219,14 +204,16 @@ def check_ref_type(repo_path, ref=None): return None, ref else: # Check current HEAD state - if repo.head_is_detached: - commit = str(repo.head.target)[:7] + if repo.is_head_detached(): + commit = repo.get_head_target()[:7] return 'commit', commit else: # HEAD points to a branch - branch_name = repo.head.shorthand + branch_name = repo.get_head_shorthand() return 'branch', branch_name + except GitBackendError: + return None, ref except Exception: return None, ref @@ -256,46 +243,49 @@ def switch_ref(plugin, ref, discard_changes=False): if changes: raise PluginDirtyError(plugin.plugin_id, changes) - import pygit2 - - repo = pygit2.Repository(str(plugin.local_path)) + # Use abstracted git operations for ref switching + backend = git_backend() + repo = backend.create_repository(plugin.local_path) # Get old ref and commit - old_commit = str(repo.head.target) - old_ref = repo.head.shorthand if not repo.head_is_detached else old_commit[:7] + old_commit = repo.get_head_target() + old_ref = repo.get_head_shorthand() if not repo.is_head_detached() else old_commit[:7] # Fetch latest from remote - remote = repo.remotes['origin'] - from picard.plugin3.plugin import GitRemoteCallbacks - - callbacks = GitRemoteCallbacks() - remote.fetch(callbacks=callbacks) + callbacks = backend.create_remote_callbacks() + origin_remote = repo.get_remote('origin') + repo.fetch_remote(origin_remote, None, callbacks._callbacks) # Find the ref try: + references = repo.get_references() + # Try as branch first branch_ref = f'refs/remotes/origin/{ref}' - if branch_ref in repo.references: - commit = repo.references[branch_ref].peel() + if branch_ref in references: + commit_obj = repo.revparse_single(branch_ref) + commit = repo.peel_to_commit(commit_obj) repo.checkout_tree(commit) # Detach HEAD first to avoid "cannot force update current branch" error repo.set_head(commit.id) # Set branch to track remote - branch = repo.branches.local.create(ref, commit, force=True) - branch.upstream = repo.branches.remote[f'origin/{ref}'] + branches = repo.get_branches() + branch = branches.local.create(ref, commit_obj, force=True) + branch.upstream = branches.remote[f'origin/{ref}'] # Now point HEAD to the branch repo.set_head(f'refs/heads/{ref}') log.info('Switched plugin %s to branch %s', plugin.plugin_id, ref) - return old_ref, ref, old_commit, str(commit.id) + return old_ref, ref, old_commit, commit.id # Try as tag tag_ref = f'refs/tags/{ref}' - if tag_ref in repo.references: - commit = repo.references[tag_ref].peel() + if tag_ref in references: + commit_obj = repo.revparse_single(tag_ref) + commit = repo.peel_to_commit(commit_obj) repo.checkout_tree(commit) repo.set_head(commit.id) log.info('Switched plugin %s to tag %s', plugin.plugin_id, ref) - return old_ref, ref, old_commit, str(commit.id) + return old_ref, ref, old_commit, commit.id # Try as commit hash try: @@ -303,7 +293,7 @@ def switch_ref(plugin, ref, discard_changes=False): repo.checkout_tree(commit) repo.set_head(commit.id) log.info('Switched plugin %s to commit %s', plugin.plugin_id, ref) - return old_ref, ref[:7], old_commit, str(commit.id) + return old_ref, ref[:7], old_commit, commit.id except KeyError: pass diff --git a/picard/plugin3/git_utils.py b/picard/plugin3/git_utils.py index 9948d10e2f..f63a418838 100644 --- a/picard/plugin3/git_utils.py +++ b/picard/plugin3/git_utils.py @@ -133,9 +133,11 @@ def check_local_repo_dirty(url): return False try: - import pygit2 + from picard.plugin3.git_factory import git_backend - repo = pygit2.Repository(str(local_path)) - return bool(repo.status()) + backend = git_backend() + repo = backend.create_repository(local_path) + status = repo.get_status() + return bool(status) except Exception: return False diff --git a/picard/plugin3/manager.py b/picard/plugin3/manager.py index 2efe128dd1..353ebee46e 100644 --- a/picard/plugin3/manager.py +++ b/picard/plugin3/manager.py @@ -293,7 +293,7 @@ def fetch_all_git_refs(self, url, use_cache=True): for ref in remote_refs: ref_name = ref.name if hasattr(ref, 'name') else str(ref) - commit_id = str(ref.oid) if hasattr(ref, 'oid') else None + commit_id = str(ref.id) if hasattr(ref, 'id') else None if ref_name.startswith('refs/heads/'): branch_name = ref_name[len('refs/heads/') :] @@ -813,15 +813,16 @@ def _install_from_local_directory( if is_git_repo: # Check if source repository has uncommitted changes try: - import pygit2 + from picard.plugin3.git_factory import git_backend - source_repo = pygit2.Repository(str(local_path)) - if source_repo.status(): + backend = git_backend() + source_repo = backend.create_repository(local_path) + if source_repo.get_status(): log.warning('Installing from local repository with uncommitted changes: %s', local_path) # If no ref specified, use the current branch - if not ref and not source_repo.head_is_detached: - ref = source_repo.head.shorthand + if not ref and not source_repo.is_head_detached(): + ref = source_repo.get_head_shorthand() log.debug('Using current branch from local repo: %s', ref) except Exception: pass # Ignore errors checking status @@ -1034,18 +1035,20 @@ def update_plugin(self, plugin: Plugin, discard_changes=False): old_commit, new_commit = source.update(plugin.local_path, single_branch=True) # Get commit date and resolve annotated tags to actual commit - import pygit2 + from picard.plugin3.git_backend import GitObjectType + from picard.plugin3.git_factory import git_backend - repo = pygit2.Repository(plugin.local_path.absolute()) - obj = repo.get(new_commit) - assert obj is not None + backend = git_backend() + repo = backend.create_repository(plugin.local_path) + obj = repo.revparse_single(new_commit) # Peel tag to commit if needed - if obj.type == pygit2.GIT_OBJECT_TAG: - commit = obj.peel(pygit2.GIT_OBJECT_COMMIT) # type: ignore[call-overload] - new_commit = str(commit.id) # Use actual commit ID, not tag object ID + if obj.type == GitObjectType.TAG: + commit = repo.peel_to_commit(obj) + new_commit = commit.id # Use actual commit ID, not tag object ID else: commit = obj - commit_date = commit.commit_time + # Get commit date using backend + commit_date = repo.get_commit_date(commit.id) repo.free() # Reload manifest to get new version @@ -1111,14 +1114,16 @@ def check_updates(self): continue try: - import pygit2 + from picard.plugin3.git_factory import git_backend - repo = pygit2.Repository(plugin.local_path.absolute()) - current_commit = str(repo.head.target) + backend = git_backend() + repo = backend.create_repository(plugin.local_path) + current_commit = repo.get_head_target() # Fetch without updating (suppress progress output) - for remote in repo.remotes: - remote.fetch() + callbacks = backend.create_remote_callbacks() + for remote in repo.get_remotes(): + repo.fetch_remote(remote, None, callbacks._callbacks) # Update version tag cache from fetched repo if plugin has versioning_scheme registry_plugin = self._registry.find_plugin(url=metadata.url) @@ -1167,14 +1172,16 @@ def check_updates(self): obj = repo.revparse_single(ref) # Peel annotated tags to get the actual commit - if obj.type == pygit2.GIT_OBJECT_TAG: - commit = obj.peel(pygit2.GIT_OBJECT_COMMIT) + from picard.plugin3.git_backend import GitObjectType + + if obj.type == GitObjectType.TAG: + commit = repo.peel_to_commit(obj) else: commit = obj - latest_commit = str(commit.id) - # Get commit date - latest_commit_date = commit.commit_time + latest_commit = commit.id + # Get commit date using backend + latest_commit_date = repo.get_commit_date(commit.id) except KeyError: # Ref not found, skip this plugin continue diff --git a/picard/plugin3/plugin.py b/picard/plugin3/plugin.py index 319182924e..2deaf65e14 100644 --- a/picard/plugin3/plugin.py +++ b/picard/plugin3/plugin.py @@ -33,18 +33,12 @@ unset_plugin_uuid, ) from picard.plugin3.api import PluginApi +from picard.plugin3.git_backend import GitBackendError +from picard.plugin3.git_factory import git_backend from picard.plugin3.manifest import PluginManifest from picard.version import Version -try: - import pygit2 - - HAS_PYGIT2 = True -except ImportError: - HAS_PYGIT2 = False - pygit2 = None # type: ignore[assignment] - try: import hashlib @@ -92,46 +86,6 @@ class PluginState(Enum): ERROR = 'error' # Failed to load or enable -if HAS_PYGIT2: - - class GitRemoteCallbacks(pygit2.RemoteCallbacks): - def __init__(self): - super().__init__() - self._attempted = False - - def transfer_progress(self, stats): - pass # Suppress progress output - - def credentials(self, url, username_from_url, allowed_types): - """Provide credentials for git operations. - - Supports SSH keys and username/password authentication. - Falls back to system git credential helpers. - """ - # Prevent infinite retry loops - if self._attempted: - return None - self._attempted = True - - if allowed_types & pygit2.GIT_CREDENTIAL_SSH_KEY: - # Try SSH key authentication with default key - try: - return pygit2.Keypair('git', None, None, '') - except Exception: - return None - elif allowed_types & pygit2.GIT_CREDENTIAL_USERPASS_PLAINTEXT: - # Try default credential helper - try: - return pygit2.Username('git') - except Exception: - return None - return None -else: - - class GitRemoteCallbacks: # type: ignore[no-redef] - pass - - class PluginSourceSyncError(Exception): pass @@ -164,8 +118,7 @@ class PluginSourceGit(PluginSource): def __init__(self, url: str, ref: str | None = None): super().__init__() - if not HAS_PYGIT2: - raise PluginSourceSyncError("pygit2 is not available. Install it to use git-based plugin sources.") + # Git backend will handle availability check # Note: url can be a local directory self.url = url self.ref = ref @@ -175,14 +128,14 @@ def _list_available_refs(self, repo, limit=20): """List available refs in repository. Args: - repo: pygit2.Repository instance + repo: GitRepository instance limit: Maximum number of refs to return Returns: str: Comma-separated list of ref names """ refs = [] - all_refs = list(repo.references) # Convert References to list + all_refs = repo.list_references() for ref in all_refs: if ref.startswith('refs/heads/'): refs.append(ref[11:]) # Remove 'refs/heads/' prefix @@ -205,7 +158,7 @@ def _retry_git_operation(self, operation, max_retries=GIT_OPERATION_MAX_RETRIES) for attempt in range(max_retries): try: return operation() - except pygit2.GitError as e: + except Exception as e: error_msg = str(e).lower() is_network_error = any( keyword in error_msg @@ -226,10 +179,12 @@ def _retry_git_operation(self, operation, max_retries=GIT_OPERATION_MAX_RETRIES) else: raise - def _resolve_to_commit(self, obj): + def _resolve_to_commit(self, obj, repo=None): """Resolve a git object to a commit, peeling tags if needed.""" - if hasattr(obj, 'type') and obj.type == pygit2.GIT_OBJECT_TAG: - return obj.peel(pygit2.GIT_OBJECT_COMMIT) + from picard.plugin3.git_backend import GitObjectType + + if hasattr(obj, 'type') and obj.type == GitObjectType.TAG and repo: + return repo.peel_to_commit(obj) return obj def sync(self, target_directory: Path, shallow: bool = False, single_branch: bool = False, fetch_ref: bool = False): @@ -241,20 +196,34 @@ def sync(self, target_directory: Path, shallow: bool = False, single_branch: boo single_branch: If True, only clone/fetch the specific branch fetch_ref: If True and repo exists, fetch the ref before checking out (for switch-ref) """ + backend = git_backend() + if target_directory.is_dir(): - repo = pygit2.Repository(target_directory.absolute()) + repo = backend.create_repository(target_directory.absolute()) # If fetch_ref is True, fetch the specific ref we're switching to if fetch_ref and self.ref: - for remote in repo.remotes: + callbacks = backend.create_remote_callbacks() + try: + origin_remote = repo.get_remote('origin') refspec = f'+refs/heads/{self.ref}:refs/remotes/origin/{self.ref}' try: - self._retry_git_operation(lambda: remote.fetch([refspec], callbacks=GitRemoteCallbacks())) + self._retry_git_operation( + lambda: repo.fetch_remote(origin_remote, refspec, callbacks._callbacks) + ) except Exception: # If specific refspec fails, try fetching all (might be a tag or commit) - self._retry_git_operation(lambda: remote.fetch(callbacks=GitRemoteCallbacks())) + self._retry_git_operation(lambda: repo.fetch_remote(origin_remote, None, callbacks._callbacks)) + except (KeyError, GitBackendError): + # No origin remote, skip fetch + pass else: - for remote in repo.remotes: - self._retry_git_operation(lambda: remote.fetch(callbacks=GitRemoteCallbacks())) + callbacks = backend.create_remote_callbacks() + try: + origin_remote = repo.get_remote('origin') + self._retry_git_operation(lambda: repo.fetch_remote(origin_remote, None, callbacks._callbacks)) + except (KeyError, GitBackendError): + # No origin remote, skip fetch + pass else: depth = 1 if shallow else 0 # Strip origin/ prefix if present for checkout_branch @@ -263,17 +232,17 @@ def sync(self, target_directory: Path, shallow: bool = False, single_branch: boo checkout_branch = self.ref[7:] if self.ref.startswith('origin/') else self.ref def clone_operation(): - return pygit2.clone_repository( + return backend.clone_repository( self.url, - target_directory.absolute(), - callbacks=GitRemoteCallbacks(), + target_directory, + callbacks=backend.create_remote_callbacks(), depth=depth, checkout_branch=checkout_branch, ) try: repo = self._retry_git_operation(clone_operation) - except (KeyError, pygit2.GitError) as e: + except Exception as e: # Check if it's a repository-level 404/not found error (not branch-level) error_msg = str(e).lower() # Only catch repository not found, not branch/ref not found @@ -292,10 +261,10 @@ def clone_operation(): elif checkout_branch: def fallback_clone(): - return pygit2.clone_repository( + return backend.clone_repository( self.url, - target_directory.absolute(), - callbacks=GitRemoteCallbacks(), + target_directory, + callbacks=backend.create_remote_callbacks(), depth=depth, ) @@ -305,24 +274,25 @@ def fallback_clone(): # If shallow clone and ref specified, fetch tags to ensure tags are available for updates if shallow and self.ref: - for remote in repo.remotes: - try: - remote.fetch(['+refs/tags/*:refs/tags/*'], callbacks=GitRemoteCallbacks()) - except Exception: - pass # Tags might not exist or fetch might fail + callbacks = backend.create_remote_callbacks() + try: + origin_remote = repo.get_remote('origin') + repo.fetch_remote(origin_remote, '+refs/tags/*:refs/tags/*', callbacks._callbacks) + except Exception: + pass # Tags might not exist or fetch might fail if self.ref: try: commit = repo.revparse_single(self.ref) self.resolved_ref = self.ref - except KeyError: + except (KeyError, Exception): # If ref starts with 'origin/', try without it if self.ref.startswith('origin/'): try: ref_without_origin = self.ref[7:] # Remove 'origin/' prefix commit = repo.revparse_single(ref_without_origin) self.resolved_ref = ref_without_origin - except KeyError: + except (KeyError, GitBackendError): available_refs = self._list_available_refs(repo) raise KeyError( f"Could not find ref '{self.ref}' or '{ref_without_origin}'. " @@ -333,12 +303,12 @@ def fallback_clone(): try: commit = repo.revparse_single(f'origin/{self.ref}') self.resolved_ref = f'origin/{self.ref}' - except KeyError: + except (KeyError, GitBackendError): # Try as local branch refs/heads/ try: commit = repo.revparse_single(f'refs/heads/{self.ref}') self.resolved_ref = self.ref - except KeyError: + except (KeyError, GitBackendError): available_refs = self._list_available_refs(repo) raise KeyError( f"Could not find ref '{self.ref}'. " @@ -350,21 +320,21 @@ def fallback_clone(): try: commit = repo.revparse_single('HEAD') # Get the branch name that HEAD points to - if repo.head_is_detached: - self.resolved_ref = short_commit_id(str(commit.id)) + if repo.is_head_detached(): + self.resolved_ref = short_commit_id(commit.id) else: # Get branch name from HEAD - head_ref = repo.head.name + head_ref = repo.get_head_name() if head_ref.startswith('refs/heads/'): self.resolved_ref = head_ref[11:] # Remove 'refs/heads/' prefix else: self.resolved_ref = head_ref - except KeyError: + except (KeyError, GitBackendError): # HEAD not set, try 'main' or first available branch try: commit = repo.revparse_single('main') self.resolved_ref = 'main' - except KeyError: + except (KeyError, GitBackendError): # Find first available branch branches = list(repo.branches.local) if branches: @@ -392,9 +362,12 @@ def fallback_clone(): raise PluginSourceSyncError('No branches found in repository') from None # hard reset to passed ref or HEAD - commit = self._resolve_to_commit(commit) - repo.reset(commit.id, pygit2.enums.ResetMode.HARD) - commit_id = str(commit.id) + commit = self._resolve_to_commit(commit, repo) + # Use backend for reset operation + from picard.plugin3.git_backend import GitResetMode + + repo.reset(commit.id, GitResetMode.HARD) + commit_id = commit.id repo.free() return commit_id @@ -406,8 +379,9 @@ def update(self, target_directory: Path, single_branch: bool = False): single_branch: If True, only fetch the current ref """ - repo = pygit2.Repository(target_directory.absolute()) - old_commit = str(repo.head.target) + backend = git_backend() + repo = backend.create_repository(target_directory.absolute()) + old_commit = repo.get_head_target() # Check if currently on a tag current_is_tag = False @@ -418,17 +392,22 @@ def update(self, target_directory: Path, single_branch: bool = False): repo.revparse_single(f'refs/tags/{self.ref}') current_is_tag = True current_tag = self.ref - except KeyError: + except (KeyError, GitBackendError): pass - for remote in repo.remotes: + callbacks = backend.create_remote_callbacks() + try: + origin_remote = repo.get_remote('origin') if single_branch and self.ref and not current_is_tag: # Fetch only the specific ref (branch) refspec = f'+refs/heads/{self.ref}:refs/remotes/origin/{self.ref}' - remote.fetch([refspec], callbacks=GitRemoteCallbacks()) + repo.fetch_remote(origin_remote, refspec, callbacks._callbacks) else: # Fetch all refs (including tags if on a tag) - remote.fetch(callbacks=GitRemoteCallbacks()) + repo.fetch_remote(origin_remote, None, callbacks._callbacks) + except (KeyError, GitBackendError): + # No origin remote, skip fetch + pass # If on a tag, try to find latest tag if current_is_tag and current_tag: @@ -445,20 +424,22 @@ def update(self, target_directory: Path, single_branch: bool = False): try: commit = repo.revparse_single(f'origin/{self.ref}') ref_to_use = f'origin/{self.ref}' - except KeyError: + except (KeyError, GitBackendError): # Fall back to original ref (might be tag or commit hash) try: commit = repo.revparse_single(f'refs/tags/{self.ref}') - except KeyError: + except (KeyError, GitBackendError): commit = repo.revparse_single(self.ref) else: commit = repo.revparse_single(ref_to_use) else: commit = repo.revparse_single('HEAD') - commit = self._resolve_to_commit(commit) - repo.reset(commit.id, pygit2.enums.ResetMode.HARD) - new_commit = str(commit.id) + commit = self._resolve_to_commit(commit, repo) + from picard.plugin3.git_backend import GitResetMode + + repo.reset(commit.id, GitResetMode.HARD) + new_commit = commit.id repo.free() return old_commit, new_commit @@ -472,13 +453,9 @@ def _find_latest_tag(self, repo, current_tag: str): - release-1.0.0, release/1.0.0 - 2024.11.30 (date-based) """ - # Get all tags (use listall_references to include fetched tags) + # Get all tags (use abstracted list_references) tags = [] - try: - all_refs = repo.listall_references() - except AttributeError: - # Fallback for older pygit2 - all_refs = list(repo.references) + all_refs = repo.list_references() for ref_name in all_refs: if ref_name.startswith('refs/tags/'): @@ -589,16 +566,14 @@ def read_manifest(self): def get_current_commit_id(self): """Get the current commit ID of the plugin if it's a git repository.""" - if not HAS_PYGIT2: - return None - git_dir = self.local_path / '.git' if not git_dir.exists(): return None try: - repo = pygit2.Repository(str(self.local_path)) - commit_id = str(repo.head.target) + backend = git_backend() + repo = backend.create_repository(self.local_path) + commit_id = repo.get_head_target() repo.free() return short_commit_id(commit_id) except Exception: diff --git a/picard/plugin3/plugin_metadata.py b/picard/plugin3/plugin_metadata.py index 3064dc4ef7..6b0c4cf533 100644 --- a/picard/plugin3/plugin_metadata.py +++ b/picard/plugin3/plugin_metadata.py @@ -228,27 +228,32 @@ def get_plugin_refs_info(self, identifier, plugins): # Detect current ref from local git repo (overrides metadata) if plugin.local_path: try: - import pygit2 + from picard.plugin3.git_factory import git_backend - repo = pygit2.Repository(str(plugin.local_path)) - current_commit = str(repo.head.target) + backend = git_backend() + repo = backend.create_repository(plugin.local_path) + current_commit = repo.get_head_target() # Check if current commit matches a tag (prefer tag over branch) current_ref = None - for ref_name in repo.references: + for ref_name in repo.list_references(): if ref_name.startswith('refs/tags/'): tag_name = ref_name[10:] if tag_name.endswith('^{}'): continue - ref = repo.references[ref_name] - target = ref.peel() - if str(target.id) == current_commit: - current_ref = tag_name - break + try: + obj = repo.revparse_single(ref_name) + target = repo.peel_to_commit(obj) + if target.id == current_commit: + current_ref = tag_name + break + except Exception: + # Skip invalid tags and continue to next one + continue # nosec try_except_continue # If no tag found, use branch name - if not current_ref and not repo.head_is_detached: - current_ref = repo.head.shorthand + if not current_ref and not repo.is_head_detached(): + current_ref = repo.get_head_shorthand() except Exception: pass # Ignore errors, use metadata values else: diff --git a/picard/plugin3/pygit2_backend.py b/picard/plugin3/pygit2_backend.py new file mode 100644 index 0000000000..afd95fa2d4 --- /dev/null +++ b/picard/plugin3/pygit2_backend.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2025 Philipp Wolfer, Laurent Monin +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Pygit2 backend implementation.""" + +from pathlib import Path +import tempfile +from typing import ( + Any, + Dict, + List, + Optional, +) + + +try: + import pygit2 + + HAS_PYGIT2 = True +except ImportError: + HAS_PYGIT2 = False + pygit2 = None + +from picard import log +from picard.plugin3.git_backend import ( + GitBackend, + GitCommitError, + GitObject, + GitObjectType, + GitRef, + GitReferenceError, + GitRemoteCallbacks, + GitRepository, + GitRepositoryError, + GitResetMode, + GitStatusFlag, + _log_git_call, +) + + +class Pygit2RemoteCallbacks(GitRemoteCallbacks): + def __init__(self): + if not HAS_PYGIT2: + return + self._callbacks = pygit2.RemoteCallbacks() + self._callbacks.transfer_progress = self._transfer_progress + self._callbacks.credentials = self._credentials + self._attempted = False + + def _transfer_progress(self, stats): + pass # Silent progress + + def _credentials(self, url, username_from_url, allowed_types): + if self._attempted: + return None + self._attempted = True + + if allowed_types & pygit2.GIT_CREDENTIAL_SSH_KEY: + try: + return pygit2.Keypair('git', None, None, '') + except (pygit2.GitError, OSError): + return None + elif allowed_types & pygit2.GIT_CREDENTIAL_USERPASS_PLAINTEXT: + try: + return pygit2.Username('git') + except (pygit2.GitError, OSError): + return None + return None + + +class Pygit2Repository(GitRepository): + def __init__(self, repo): + self._repo = repo + + def get_status(self) -> Dict[str, GitStatusFlag]: + _log_git_call("get_status") + status = self._repo.status() + result = {} + for filepath, flags in status.items(): + if flags == pygit2.GIT_STATUS_CURRENT: + result[filepath] = GitStatusFlag.CURRENT + elif flags == pygit2.GIT_STATUS_IGNORED: + result[filepath] = GitStatusFlag.IGNORED + else: + # Any other status means the file is modified/added/deleted/untracked + result[filepath] = GitStatusFlag.MODIFIED + return result + + def get_head_target(self) -> str: + _log_git_call("get_head_target") + return str(self._repo.head.target) + + def is_head_detached(self) -> bool: + _log_git_call("is_head_detached") + return self._repo.head_is_detached + + def get_head_shorthand(self) -> str: + _log_git_call("get_head_shorthand") + return self._repo.head.shorthand + + def revparse_single(self, ref: str) -> GitObject: + _log_git_call("revparse_single", ref) + try: + obj = self._repo.revparse_single(ref) + obj_type = GitObjectType.COMMIT if obj.type == pygit2.GIT_OBJECT_COMMIT else GitObjectType.TAG + return GitObject(str(obj.id), obj_type) + except (pygit2.GitError, KeyError) as e: + raise GitReferenceError(f"Failed to resolve reference '{ref}': {e}") from e + + def get_head_name(self) -> str: + _log_git_call("get_head_name") + return self._repo.head.name + + def peel_to_commit(self, obj: GitObject) -> GitObject: + _log_git_call("peel_to_commit", obj.id) + pygit_obj = self._repo.get(obj.id) + if pygit_obj.type == pygit2.GIT_OBJECT_TAG: + commit = pygit_obj.peel(pygit2.GIT_OBJECT_COMMIT) + return GitObject(str(commit.id), GitObjectType.COMMIT) + return obj + + def reset(self, commit_id: str, mode: GitResetMode): + _log_git_call("reset", commit_id, mode.value) + self._repo.reset(commit_id, pygit2.enums.ResetMode.HARD) + + def checkout_tree(self, obj: GitObject): + _log_git_call("checkout_tree", obj.id) + pygit_obj = self._repo.get(obj.id) + self._repo.checkout_tree(pygit_obj) + + def set_head(self, target: str): + _log_git_call("set_head", target) + # If target looks like a commit hash (40 hex chars), convert to pygit2.Oid + if len(target) == 40 and all(c in '0123456789abcdef' for c in target.lower()): + import pygit2 + + oid = pygit2.Oid(hex=target) + self._repo.set_head(oid) + else: + # It's a reference name + self._repo.set_head(target) + + def list_references(self) -> List[str]: + _log_git_call("list_references") + try: + return self._repo.listall_references() + except AttributeError: + return list(self._repo.references) + + def get_references(self) -> List[str]: + _log_git_call("get_references") + try: + return self._repo.listall_references() + except AttributeError: + return list(self._repo.references) + + def get_remotes(self) -> List[Any]: + _log_git_call("get_remotes") + return self._repo.remotes + + def get_remote(self, name: str) -> Any: + _log_git_call("get_remote", name) + return self._repo.remotes[name] + + def create_remote(self, name: str, url: str) -> Any: + _log_git_call("create_remote", name, url) + return self._repo.remotes.create(name, url) + + def get_branches(self) -> Any: + _log_git_call("get_branches") + return self._repo.branches + + def get_commit_date(self, commit_id: str) -> int: + _log_git_call("get_commit_date", commit_id) + commit = self._repo.get(commit_id) + return commit.commit_time + + def fetch_remote(self, remote, refspec: str = None, callbacks=None): + _log_git_call("fetch_remote", str(remote), refspec) + if refspec: + remote.fetch([refspec], callbacks=callbacks) + else: + remote.fetch(callbacks=callbacks) + + def free(self): + _log_git_call("free") + self._repo.free() + + +class Pygit2Backend(GitBackend): + def __init__(self): + if not HAS_PYGIT2: + raise ImportError("pygit2 not available") + + def create_repository(self, path: Path) -> GitRepository: + _log_git_call("create_repository", str(path)) + try: + repo = pygit2.Repository(str(path)) + return Pygit2Repository(repo) + except pygit2.GitError as e: + raise GitRepositoryError(f"Failed to open repository at '{path}': {e}") from e + + def init_repository(self, path: Path, bare: bool = False) -> GitRepository: + _log_git_call("init_repository", str(path), bare) + repo = pygit2.init_repository(str(path), bare=bare) + return Pygit2Repository(repo) + + def create_commit( + self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "test@example.com" + ) -> str: + _log_git_call("create_commit", message, author_name, author_email) + pygit_repo = repo._repo + index = pygit_repo.index + index.add_all() + index.write() + tree = index.write_tree() + author = pygit2.Signature(author_name, author_email) + commit_id = pygit_repo.create_commit('refs/heads/main', author, author, message, tree, []) + pygit_repo.set_head('refs/heads/main') + return str(commit_id) + + def create_tag( + self, + repo: GitRepository, + tag_name: str, + commit_id: str, + message: str = "", + author_name: str = "Test", + author_email: str = "test@example.com", + ): + _log_git_call("create_tag", tag_name, commit_id, message) + pygit_repo = repo._repo + author = pygit2.Signature(author_name, author_email) + pygit_repo.create_tag(tag_name, commit_id, pygit2.GIT_OBJECT_COMMIT, author, message) + + def create_branch(self, repo: GitRepository, branch_name: str, commit_id: str): + _log_git_call("create_branch", branch_name, commit_id) + pygit_repo = repo._repo + # Create branch reference pointing to the commit + pygit_repo.create_reference(f'refs/heads/{branch_name}', commit_id) + + def add_and_commit_files( + self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "test@example.com" + ) -> str: + _log_git_call("add_and_commit_files", message, author_name, author_email) + try: + pygit_repo = repo._repo + index = pygit_repo.index + index.add_all() + index.write() + tree = index.write_tree() + author = pygit2.Signature(author_name, author_email) + + # Get current HEAD as parent, or empty list if no commits yet + try: + parent = [pygit_repo.head.target] + except pygit2.GitError: + parent = [] + + commit_id = pygit_repo.create_commit('refs/heads/main', author, author, message, tree, parent) + if not parent: # First commit, set HEAD + pygit_repo.set_head('refs/heads/main') + return str(commit_id) + except pygit2.GitError as e: + raise GitCommitError(f"Failed to create commit: {e}") from e + + def reset_hard(self, repo: GitRepository, commit_id: str): + _log_git_call("reset_hard", commit_id) + pygit_repo = repo._repo + pygit_repo.reset(commit_id, pygit2.enums.ResetMode.HARD) + + def create_reference(self, repo: GitRepository, ref_name: str, commit_id: str): + _log_git_call("create_reference", ref_name, commit_id) + pygit_repo = repo._repo + pygit_repo.create_reference(ref_name, commit_id) + + def set_head_detached(self, repo: GitRepository, commit_id: str): + _log_git_call("set_head_detached", commit_id) + pygit_repo = repo._repo + + # To create a true detached HEAD, we need to make HEAD point directly to a commit + # rather than to a branch reference + oid = pygit2.Oid(hex=commit_id) + + # Delete the HEAD symbolic reference and create a direct reference + try: + # Remove the symbolic HEAD reference + head_ref = pygit_repo.references['HEAD'] + head_ref.delete() + except (KeyError, pygit2.GitError): + # HEAD reference may not exist or may not be deletable + pass + + # Create a new HEAD that points directly to the commit + pygit_repo.references.create('HEAD', oid, force=True) + pygit_repo.checkout_head() + + def clone_repository(self, url: str, path: Path, **options) -> GitRepository: + _log_git_call("clone_repository", url, str(path), **options) + callbacks = options.pop('callbacks', None) + if callbacks and isinstance(callbacks, Pygit2RemoteCallbacks): + options['callbacks'] = callbacks._callbacks + elif callbacks: + # Handle backend callbacks + options['callbacks'] = callbacks._callbacks + repo = pygit2.clone_repository(url, str(path.absolute()), **options) + return Pygit2Repository(repo) + + def fetch_remote_refs(self, url: str, **options) -> Optional[List[GitRef]]: + _log_git_call("fetch_remote_refs", url, **options) + try: + with tempfile.TemporaryDirectory() as tmpdir: + repo = pygit2.init_repository(tmpdir, bare=True) + remote = repo.remotes.create('origin', url) + + use_callbacks = options.get('use_callbacks', True) + if use_callbacks: + callbacks = self.create_remote_callbacks() + remote_refs = remote.list_heads(callbacks=callbacks._callbacks) + else: + remote_refs = remote.list_heads() + + return [GitRef(ref.name, getattr(ref, 'target', None)) for ref in remote_refs] + except Exception as e: + log.warning('Failed to fetch remote refs from %s: %s', url, e) + return None + + def create_remote_callbacks(self) -> GitRemoteCallbacks: + _log_git_call("create_remote_callbacks") + return Pygit2RemoteCallbacks() diff --git a/picard/plugin3/refs_cache.py b/picard/plugin3/refs_cache.py index 223a7ef7b9..f6be7fdc91 100644 --- a/picard/plugin3/refs_cache.py +++ b/picard/plugin3/refs_cache.py @@ -371,7 +371,7 @@ def update_cache_from_local_repo(self, repo_path, url, versioning_scheme): Returns: list: Filtered tags or empty list """ - import pygit2 + from picard.plugin3.git_factory import git_backend # Parse versioning scheme pattern = self.parse_versioning_scheme(versioning_scheme) @@ -379,10 +379,11 @@ def update_cache_from_local_repo(self, repo_path, url, versioning_scheme): return [] try: - repo = pygit2.Repository(str(repo_path)) + backend = git_backend() + repo = backend.create_repository(repo_path) # Filter and sort tags - tags = self.filter_tags(repo.references, pattern) + tags = self.filter_tags(repo.get_references(), pattern) tags = self.sort_tags(tags, versioning_scheme) # Update cache diff --git a/picard/tagger.py b/picard/tagger.py index 73827e8001..96118ff0e9 100644 --- a/picard/tagger.py +++ b/picard/tagger.py @@ -137,10 +137,17 @@ try: - from picard.plugin3.cli import PluginCLI - from picard.plugin3.manager import PluginManager + from picard.plugin3.git_factory import has_git_backend - HAS_PLUGIN3 = True + if has_git_backend(): + from picard.plugin3.cli import PluginCLI + from picard.plugin3.manager import PluginManager + + HAS_PLUGIN3 = True + else: + HAS_PLUGIN3 = False + PluginCLI = None + PluginManager = None except ImportError: HAS_PLUGIN3 = False PluginCLI = None @@ -386,7 +393,7 @@ def _init_plugins(self): self.pluginmanager3.add_directory(plugin_folder(), primary=True) else: self.pluginmanager3 = None - log.warning('Plugin3 system not available (pygit2 not installed)') + log.warning('Plugin3 system not available (git backend not available)') def _init_browser_integration(self): """Initialize browser integration""" @@ -727,7 +734,10 @@ def _autosave(): self.update_browser_integration() self.window.show() - blacklisted_plugins = self.pluginmanager3.init_plugins() + + blacklisted_plugins = [] + if self.pluginmanager3: + blacklisted_plugins = self.pluginmanager3.init_plugins() # Show warning if any plugins were blacklisted if blacklisted_plugins: @@ -1745,18 +1755,25 @@ def main(localedir=None, autoupdate=True): # Handle plugin commands with minimal initialization (no GUI) if cmdline_args.subcommand == 'plugins': if not HAS_PLUGIN3: - log.error('Plugin3 system not available. Install pygit2 to use plugin management.') + log.error('Plugin3 system not available. Git backend not available for plugin management.') sys.exit(1) app = minimal_init(cmdline_args.config_file) # noqa: F841 - app must stay alive for QCoreApplication + + # Initialize debug options for CLI + if cmdline_args.debug_opts: + DebugOpt.from_string(cmdline_args.debug_opts) + # Ensure DEBUG level is enabled when debug options are used + log.set_verbosity(logging.DEBUG) + from picard.plugin3.manager import PluginManager from picard.plugin3.output import PluginOutput manager = PluginManager() manager.add_directory(USER_PLUGIN_DIR, primary=True) - # Suppress INFO logs for cleaner CLI output unless in debug mode - if not cmdline_args.debug: + # Suppress INFO logs for cleaner CLI output unless in debug mode or debug options are enabled + if not cmdline_args.debug and not cmdline_args.debug_opts: log.set_verbosity(logging.WARNING) # Create output with color setting from args diff --git a/picard/util/versions.py b/picard/util/versions.py index 40e8d34875..cec112dace 100644 --- a/picard/util/versions.py +++ b/picard/util/versions.py @@ -53,11 +53,21 @@ 'discid-version': "Discid", 'astrcmp': "astrcmp", 'ssl-version': "SSL", + 'pygit2-version': "pygit2", } def _load_versions(): global _versions + + # Get pygit2 version if available + try: + import pygit2 + + pygit2_version = pygit2.__version__ + except ImportError: + pygit2_version = None + _versions = OrderedDict( ( ('version', PICARD_FANCY_VERSION_STR), @@ -68,6 +78,7 @@ def _load_versions(): ('discid-version', discid_version), ('astrcmp', astrcmp_implementation), ('ssl-version', QSslSocket.sslLibraryVersionString()), + ('pygit2-version', pygit2_version), ) ) diff --git a/scripts/test_plugin_commands.sh b/scripts/test_plugin_commands.sh index 18e5b7a1b0..001f476a55 100755 --- a/scripts/test_plugin_commands.sh +++ b/scripts/test_plugin_commands.sh @@ -129,7 +129,7 @@ echo # Test 24: Uninstall plugin echo "24. Uninstall $TEST_PLUGIN_ID" -$PICARD plugins --uninstall $TEST_PLUGIN_ID --yes +$PICARD plugins --remove $TEST_PLUGIN_ID --yes echo # Test 25: Verify uninstall @@ -159,7 +159,7 @@ echo # Test 30: Uninstall with purge echo "30. Uninstall with purge (delete config)" -$PICARD plugins --uninstall $TEST_PLUGIN_ID --purge --yes +$PICARD plugins --remove $TEST_PLUGIN_ID --purge --yes echo # Test 31: Verify final cleanup diff --git a/scripts/test_plugin_commands_local.sh b/scripts/test_plugin_commands_local.sh index ac163ba380..6362845707 100755 --- a/scripts/test_plugin_commands_local.sh +++ b/scripts/test_plugin_commands_local.sh @@ -128,7 +128,7 @@ echo # Test 10: Uninstall plugin echo "10. Uninstall plugin" -$PICARD plugins --uninstall $TEST_PLUGIN_UUID --yes +$PICARD plugins --remove $TEST_PLUGIN_UUID --yes echo # Test 11: Verify uninstall @@ -153,7 +153,7 @@ echo # Test 15: Uninstall with purge echo "15. Uninstall with purge (delete config)" -$PICARD plugins --uninstall $TEST_PLUGIN_UUID --purge --yes +$PICARD plugins --remove $TEST_PLUGIN_UUID --purge --yes echo # Test 16: Verify final cleanup @@ -199,7 +199,7 @@ echo # Test 21: Update plugin (should update to v1.2.0 if versioning detected) echo "21. Update plugin (may update to newer tag if available)" -$PICARD plugins --update $TEST_PLUGIN_UUID +$PICARD plugins --update $TEST_PLUGIN_UUID --yes echo # Test 22: Verify commit after update @@ -243,7 +243,7 @@ echo # Test 26: Final uninstall echo "26. Final uninstall" -$PICARD plugins --uninstall $TEST_PLUGIN_UUID --purge --yes +$PICARD plugins --remove $TEST_PLUGIN_UUID --purge --yes echo # Cleanup diff --git a/test/conftest.py b/test/conftest.py index d1946bd020..3cad9f8f5c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -117,5 +117,11 @@ def _init_config_override(): monkeypatch.setattr(ptc.PicardTestCase, "init_config", staticmethod(_init_config_override), raising=False) + # Reset git backend singleton between tests + with suppress(ModuleNotFoundError): + from picard.plugin3.git_factory import _reset_git_backend + + _reset_git_backend() + # Yield control to test session yield diff --git a/test/test_git_backend.py b/test/test_git_backend.py new file mode 100644 index 0000000000..1e300fbf24 --- /dev/null +++ b/test/test_git_backend.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2025 Philipp Wolfer, Laurent Monin +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from pathlib import Path +import unittest +from unittest.mock import Mock, patch + +from picard.plugin3.git_backend import ( + GitBackend, + GitObject, + GitObjectType, + GitRef, + GitRepository, + GitStatusFlag, +) +from picard.plugin3.git_factory import git_backend + + +class MockGitRepository(GitRepository): + """Mock implementation for testing""" + + def __init__(self): + self.status_result = {} + self.head_target = "abc123" + self.head_detached = False + self.head_shorthand = "main" + self.head_name = "refs/heads/main" + self.references = {} + + def get_status(self): + return self.status_result + + def get_head_target(self): + return self.head_target + + def is_head_detached(self): + return self.head_detached + + def get_head_shorthand(self): + return self.head_shorthand + + def get_head_name(self): + return self.head_name + + def revparse_single(self, ref): + return GitObject("abc123", GitObjectType.COMMIT) + + def peel_to_commit(self, obj): + return obj + + def reset(self, commit_id, mode): + pass + + def checkout_tree(self, obj): + pass + + def set_head(self, target): + pass + + def list_references(self): + return list(self.references.keys()) + + def get_references(self): + return self.references + + def get_remotes(self): + return {} + + def get_remote(self, name): + return Mock() + + def create_remote(self, name, url): + return Mock() + + def get_branches(self): + return Mock() + + def get_commit_date(self, commit_id): + return 1234567890 # Mock timestamp + + def fetch_remote(self, remote, refspec=None, callbacks=None): + pass + + def free(self): + pass + + +class MockGitBackend(GitBackend): + """Mock backend for testing""" + + def __init__(self): + self.repo = MockGitRepository() + + def create_repository(self, path): + return self.repo + + def init_repository(self, path, bare=False): + return self.repo + + def clone_repository(self, url, path, **options): + return self.repo + + def fetch_remote_refs(self, url, **options): + return [GitRef("main", "abc123")] + + def create_remote_callbacks(self): + return Mock() + + def get_remote(self, name): + return Mock() + + def create_commit(self, repo, message, author_name="Test", author_email="test@example.com"): + return "abc123" + + def create_tag(self, repo, tag_name, commit_id, message="", author_name="Test", author_email="test@example.com"): + pass + + def create_branch(self, repo, branch_name, commit_id): + pass + + def add_and_commit_files(self, repo, message, author_name="Test", author_email="test@example.com"): + return "abc123" + + def reset_hard(self, repo, commit_id): + pass + + def create_reference(self, repo, ref_name, commit_id): + pass + + def set_head_detached(self, repo, commit_id): + pass + + +class TestGitBackend(unittest.TestCase): + def test_git_status_flags(self): + """Test git status flag enumeration""" + self.assertEqual(GitStatusFlag.CURRENT.value, 0) + self.assertEqual(GitStatusFlag.IGNORED.value, 1) + self.assertEqual(GitStatusFlag.MODIFIED.value, 2) + + def test_git_object_types(self): + """Test git object type enumeration""" + self.assertEqual(GitObjectType.COMMIT.value, "commit") + self.assertEqual(GitObjectType.TAG.value, "tag") + + def test_git_ref_creation(self): + """Test GitRef object creation""" + ref = GitRef("main", "abc123") + self.assertEqual(ref.name, "main") + self.assertEqual(ref.target, "abc123") + + def test_git_object_creation(self): + """Test GitObject creation""" + obj = GitObject("abc123", GitObjectType.COMMIT) + self.assertEqual(obj.id, "abc123") + self.assertEqual(obj.type, GitObjectType.COMMIT) + + def test_mock_backend_operations(self): + """Test mock backend basic operations""" + backend = MockGitBackend() + + # Test repository creation + repo = backend.create_repository(Path("/test")) + self.assertIsInstance(repo, MockGitRepository) + + # Test status + status = repo.get_status() + self.assertEqual(status, {}) + + # Test head operations + self.assertEqual(repo.get_head_target(), "abc123") + self.assertFalse(repo.is_head_detached()) + self.assertEqual(repo.get_head_shorthand(), "main") + + def test_git_backend_exceptions(self): + """Test that git backend raises proper exceptions""" + from picard.plugin3 import GitReferenceError, GitRepositoryError + + backend = git_backend() + + # Test repository error + with self.assertRaises(GitRepositoryError): + backend.create_repository(Path('/nonexistent/path')) + + # Test reference error with a valid repo + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + repo_dir = Path(tmpdir) / "test-repo" + repo = backend.init_repository(repo_dir) + + with self.assertRaises(GitReferenceError): + repo.revparse_single('nonexistent-ref') + + repo.free() + + @patch('picard.plugin3.git_factory.get_git_backend') + def test_git_factory_singleton(self, mock_get_backend): + """Test git factory returns singleton""" + mock_backend = MockGitBackend() + mock_get_backend.return_value = mock_backend + + # Reset singleton + import picard.plugin3.git_factory + + picard.plugin3.git_factory._git_backend = None + + backend1 = git_backend() + backend2 = git_backend() + + self.assertIs(backend1, backend2) + mock_get_backend.assert_called_once() + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_plugins3_cli.py b/test/test_plugins3_cli.py index 1156d1a2e1..a65217a7e3 100644 --- a/test/test_plugins3_cli.py +++ b/test/test_plugins3_cli.py @@ -28,6 +28,7 @@ run_cli, ) +from picard.plugin3.git_factory import has_git_backend from picard.plugin3.manager import UpdateResult @@ -108,15 +109,8 @@ def test_validate_git_url(self): """Test validate command with git URL.""" import tempfile - try: - import pygit2 # noqa: F401 - - HAS_PYGIT2 = True - except ImportError: - HAS_PYGIT2 = False - - if not HAS_PYGIT2: - self.skipTest("pygit2 not available") + if not has_git_backend(): + self.skipTest("git backend not available") # Create a temporary git repository with tempfile.TemporaryDirectory() as tmpdir: diff --git a/test/test_plugins3_git.py b/test/test_plugins3_git.py index 46e1b4ceb5..e6215f7284 100644 --- a/test/test_plugins3_git.py +++ b/test/test_plugins3_git.py @@ -28,24 +28,27 @@ try: - import pygit2 + from picard.plugin3.git_factory import has_git_backend - HAS_PYGIT2 = True + HAS_GIT_BACKEND = has_git_backend() except ImportError: - HAS_PYGIT2 = False - pygit2 = None + HAS_GIT_BACKEND = False class TestCheckRefType(PicardTestCase): """Test check_ref_type() function that queries actual git repos.""" - def test_check_ref_type_requires_pygit2(self): - """Test that check_ref_type is only available with pygit2.""" - if not HAS_PYGIT2: - self.skipTest("pygit2 not available") + def test_check_ref_type_with_invalid_repo(self): + """Test check_ref_type with invalid repository path.""" + from picard.plugin3.git_ops import GitOperations + + # Should handle repository errors gracefully + ref_type, ref_name = GitOperations.check_ref_type(Path('/nonexistent'), 'main') + self.assertIsNone(ref_type) + self.assertEqual(ref_name, 'main') -@pytest.mark.skipif(not HAS_PYGIT2, reason="pygit2 not available") +@pytest.mark.skipif(not HAS_GIT_BACKEND, reason="git backend not available") class TestCheckRefTypeWithRepo(PicardTestCase): """Test check_ref_type() with actual git repository.""" @@ -54,20 +57,15 @@ def setUp(self): super().setUp() self.tmpdir = tempfile.mkdtemp() self.repo_dir = Path(self.tmpdir) / "test-repo" - self.repo_dir.mkdir() - # Initialize git repo - self.repo = pygit2.init_repository(str(self.repo_dir)) + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + self.repo = backend.init_repository(self.repo_dir) # Create initial commit (self.repo_dir / "file.txt").write_text("content") - index = self.repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - self.commit1 = self.repo.create_commit('refs/heads/main', author, author, 'Initial commit', tree, []) - self.repo.set_head('refs/heads/main') + self.commit1 = backend.create_commit(self.repo, 'Initial commit') def tearDown(self): """Clean up temporary directory.""" @@ -89,11 +87,12 @@ def test_check_current_branch(self): def test_check_detached_head_commit(self): """Test checking detached HEAD (commit).""" + from test.test_plugins3_helpers import backend_set_detached_head + from picard.plugin3.git_ops import GitOperations # Checkout specific commit (detached HEAD) - self.repo.set_head(self.commit1) - self.repo.checkout_head() + backend_set_detached_head(self.repo_dir, self.commit1) ref_type, ref_name = GitOperations.check_ref_type(self.repo_dir) self.assertEqual(ref_type, 'commit') @@ -101,11 +100,12 @@ def test_check_detached_head_commit(self): def test_check_tag_ref(self): """Test checking if a ref is a tag.""" + from test.test_plugins3_helpers import backend_create_tag + from picard.plugin3.git_ops import GitOperations # Create a tag - author = pygit2.Signature("Test", "test@example.com") - self.repo.create_tag('v1.0.0', self.commit1, pygit2.GIT_OBJECT_COMMIT, author, 'Version 1.0.0') + backend_create_tag(self.repo_dir, 'v1.0.0', self.commit1, 'Version 1.0.0') ref_type, ref_name = GitOperations.check_ref_type(self.repo_dir, 'v1.0.0') self.assertEqual(ref_type, 'tag') @@ -113,16 +113,14 @@ def test_check_tag_ref(self): def test_check_branch_ref(self): """Test checking if a ref is a branch.""" + from test.test_plugins3_helpers import backend_add_and_commit, backend_create_branch + from picard.plugin3.git_ops import GitOperations - # Create a dev branch + # Create a dev branch with different content (self.repo_dir / "dev.txt").write_text("dev") - index = self.repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - self.repo.create_commit('refs/heads/dev', author, author, 'Dev', tree, [self.commit1]) + dev_commit = backend_add_and_commit(self.repo_dir, 'Dev') + backend_create_branch(self.repo_dir, 'dev', dev_commit) ref_type, ref_name = GitOperations.check_ref_type(self.repo_dir, 'dev') self.assertEqual(ref_type, 'branch') @@ -147,17 +145,19 @@ def test_check_nonexistent_ref(self): def test_check_lightweight_tag(self): """Test checking a lightweight tag (ref to commit, not tag object).""" + from test.test_plugins3_helpers import backend_create_lightweight_tag + from picard.plugin3.git_ops import GitOperations # Create a lightweight tag (just a reference, no tag object) - self.repo.create_reference('refs/tags/lightweight-v1.0', self.commit1) + backend_create_lightweight_tag(self.repo_dir, 'lightweight-v1.0', self.commit1) ref_type, ref_name = GitOperations.check_ref_type(self.repo_dir, 'lightweight-v1.0') self.assertEqual(ref_type, 'tag') self.assertEqual(ref_name, 'lightweight-v1.0') -@pytest.mark.skipif(not HAS_PYGIT2, reason="pygit2 not available") +@pytest.mark.skipif(not HAS_GIT_BACKEND, reason="git backend not available") class TestPluginGitOperations(PicardTestCase): """Test git operations for plugin installation and updates.""" @@ -168,10 +168,7 @@ def setUp(self): self.plugin_dir = Path(self.tmpdir) / "test-plugin" self.plugin_dir.mkdir() - # Initialize git repo - repo = pygit2.init_repository(str(self.plugin_dir)) - - # Create MANIFEST.toml + # Create MANIFEST.toml and __init__.py, then initialize git repo manifest_content = """name = "Test Plugin" authors = ["Test Author"] version = "1.0.0" @@ -181,26 +178,22 @@ def setUp(self): license_url = "https://www.gnu.org/licenses/gpl-2.0.html" uuid = "3fa397ec-0f2a-47dd-9223-e47ce9f2d692" """ - (self.plugin_dir / "MANIFEST.toml").write_text(manifest_content) - # Create __init__.py - (self.plugin_dir / "__init__.py").write_text(""" + from test.test_plugins3_helpers import create_git_repo_with_backend + + create_git_repo_with_backend( + self.plugin_dir, + { + 'MANIFEST.toml': manifest_content, + '__init__.py': """ def enable(api): pass def disable(): pass -""") - - # Commit files - index = repo.index - index.add_all() - index.write() - - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - repo.create_commit('refs/heads/main', author, author, 'Initial commit', tree, []) - repo.set_head('refs/heads/main') +""", + }, + ) def tearDown(self): """Clean up temporary directory.""" @@ -260,13 +253,9 @@ def test_plugin_source_git_update(self): # Make a new commit in source (self.plugin_dir / "newfile.txt").write_text("new content") - repo = pygit2.Repository(str(self.plugin_dir)) - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - repo.create_commit('refs/heads/main', author, author, 'Add new file', tree, [repo.head.target]) + from test.test_plugins3_helpers import backend_add_and_commit + + backend_add_and_commit(self.plugin_dir, 'Add new file') # Update - need to use origin/main after clone source_with_remote = PluginSourceGit(str(self.plugin_dir), ref='origin/main') @@ -279,20 +268,20 @@ def test_plugin_source_git_update(self): def test_plugin_source_git_with_branch(self): """Test cloning specific branch.""" + # Create a dev branch in source + from picard.plugin3.git_factory import git_backend from picard.plugin3.plugin import PluginSourceGit - # Create a dev branch in source - repo = pygit2.Repository(str(self.plugin_dir)) - main_commit = repo.head.target + backend = git_backend() + repo = backend.create_repository(self.plugin_dir) + repo.free() # Create file on dev branch (self.plugin_dir / "dev-feature.txt").write_text("dev only") - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - dev_commit = repo.create_commit('refs/heads/dev', author, author, 'Dev feature', tree, [main_commit]) + from test.test_plugins3_helpers import backend_add_and_commit, backend_create_branch + + dev_commit = backend_add_and_commit(self.plugin_dir, 'Dev feature') + backend_create_branch(self.plugin_dir, 'dev', dev_commit) with tempfile.TemporaryDirectory() as tmpdir: # Clone dev branch @@ -306,13 +295,15 @@ def test_plugin_source_git_with_branch(self): def test_plugin_source_git_with_tag(self): """Test cloning specific tag.""" + # Create a tag in source + from picard.plugin3.git_factory import git_backend from picard.plugin3.plugin import PluginSourceGit - # Create a tag in source - repo = pygit2.Repository(str(self.plugin_dir)) - commit = repo.head.target - author = pygit2.Signature("Test", "test@example.com") - repo.create_tag('v1.0.0', commit, pygit2.GIT_OBJECT_COMMIT, author, 'Version 1.0.0') + backend = git_backend() + repo = backend.create_repository(self.plugin_dir) + commit = repo.get_head_target() + backend.create_tag(repo, 'v1.0.0', commit, 'Version 1.0.0') + repo.free() with tempfile.TemporaryDirectory() as tmpdir: target = Path(tmpdir) / "cloned" @@ -325,19 +316,19 @@ def test_plugin_source_git_with_tag(self): def test_plugin_source_git_with_commit_hash(self): """Test cloning specific commit.""" + from picard.plugin3.git_factory import git_backend from picard.plugin3.plugin import PluginSourceGit - repo = pygit2.Repository(str(self.plugin_dir)) - first_commit = str(repo.head.target) + backend = git_backend() + repo = backend.create_repository(self.plugin_dir) + first_commit = repo.get_head_target() + repo.free() # Make another commit (self.plugin_dir / "second.txt").write_text("second") - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - repo.create_commit('refs/heads/main', author, author, 'Second commit', tree, [repo.head.target]) + from test.test_plugins3_helpers import backend_add_and_commit + + backend_add_and_commit(self.plugin_dir, 'Second commit') with tempfile.TemporaryDirectory() as tmpdir: # Clone first commit @@ -379,18 +370,19 @@ def test_manager_install_from_git(self): def test_manager_install_with_ref(self): """Test installing plugin from specific ref.""" + # Create dev branch + from picard.plugin3.git_factory import git_backend from picard.plugin3.manager import PluginManager - # Create dev branch - repo = pygit2.Repository(str(self.plugin_dir)) - main_commit = repo.head.target + backend = git_backend() + repo = backend.create_repository(self.plugin_dir) + repo.free() + (self.plugin_dir / "dev.txt").write_text("dev") - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - repo.create_commit('refs/heads/dev', author, author, 'Dev', tree, [main_commit]) + from test.test_plugins3_helpers import backend_add_and_commit, backend_create_branch + + dev_commit = backend_add_and_commit(self.plugin_dir, 'Dev') + backend_create_branch(self.plugin_dir, 'dev', dev_commit) with tempfile.TemporaryDirectory() as tmpdir: mock_tagger = MockTagger() @@ -441,13 +433,9 @@ def test_manager_update_plugin_from_git(self): """ (self.plugin_dir / "MANIFEST.toml").write_text(manifest_content) (self.plugin_dir / "update.txt").write_text("updated") - repo = pygit2.Repository(str(self.plugin_dir)) - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - repo.create_commit('refs/heads/main', author, author, 'Update to 1.1.0', tree, [repo.head.target]) + from test.test_plugins3_helpers import backend_add_and_commit + + backend_add_and_commit(self.plugin_dir, 'Update to 1.1.0') # Update result = manager.update_plugin(plugin) @@ -486,17 +474,9 @@ def test_install_validates_manifest_from_git(self): # Create repo without MANIFEST with tempfile.TemporaryDirectory() as tmpdir: bad_plugin_dir = Path(tmpdir) / "bad-plugin" - bad_plugin_dir.mkdir() + from test.test_plugins3_helpers import backend_init_and_commit - repo = pygit2.init_repository(str(bad_plugin_dir)) - (bad_plugin_dir / "README.md").write_text("No manifest") - - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - repo.create_commit('refs/heads/main', author, author, 'Initial', tree, []) + backend_init_and_commit(bad_plugin_dir, {"README.md": "No manifest"}, 'Initial') # Try to install mock_tagger = MockTagger() @@ -512,7 +492,7 @@ def test_install_validates_manifest_from_git(self): self.assertIn('No MANIFEST.toml', str(context.exception)) -@pytest.mark.skipif(not HAS_PYGIT2, reason="pygit2 not available") +@pytest.mark.skipif(not HAS_GIT_BACKEND, reason="git backend not available") class TestCleanPythonCache(PicardTestCase): """Test clean_python_cache function.""" @@ -596,7 +576,7 @@ def test_clean_preserves_other_files(self): self.assertTrue(txt_file.exists()) -@pytest.mark.skipif(not HAS_PYGIT2, reason="pygit2 not available") +@pytest.mark.skipif(not HAS_GIT_BACKEND, reason="git backend not available") class TestCheckDirtyWorkingDir(PicardTestCase): """Test check_dirty_working_dir function.""" @@ -605,17 +585,10 @@ def setUp(self): super().setUp() self.tmpdir = tempfile.mkdtemp() self.repo_dir = Path(self.tmpdir) / "test-repo" - self.repo_dir.mkdir() - self.repo = pygit2.init_repository(str(self.repo_dir)) - (self.repo_dir / "file.txt").write_text("content") - index = self.repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - self.repo.create_commit('refs/heads/main', author, author, 'Initial', tree, []) - self.repo.set_head('refs/heads/main') + from test.test_plugins3_helpers import backend_init_and_commit + + backend_init_and_commit(self.repo_dir, {"file.txt": "content"}, 'Initial') def tearDown(self): """Clean up temporary directory.""" diff --git a/test/test_plugins3_helpers.py b/test/test_plugins3_helpers.py index 4bd2b90b19..f7755174f9 100644 --- a/test/test_plugins3_helpers.py +++ b/test/test_plugins3_helpers.py @@ -243,6 +243,140 @@ def create_mock_manager_with_manifest_validation(): return manager +def skip_if_no_git_backend(): + """Skip test if git backend is not available.""" + try: + from picard.plugin3.git_factory import has_git_backend + + if not has_git_backend(): + import pytest + + pytest.skip("git backend not available") + except ImportError: + import pytest + + pytest.skip("git backend not available") + + +def create_git_repo_with_backend(repo_path, initial_files=None): + """Create a git repository using the git backend abstraction. + + Args: + repo_path: Path to create repository + initial_files: Dict of {filename: content} to create and commit + + Returns: + str: Initial commit ID + """ + from pathlib import Path + + from picard.plugin3.git_factory import git_backend + + repo_path = Path(repo_path) + repo_path.mkdir(parents=True, exist_ok=True) + + backend = git_backend() + repo = backend.init_repository(repo_path) + + if initial_files: + # Create files + for filename, content in initial_files.items(): + (repo_path / filename).write_text(content) + + # Commit using backend + commit_id = backend.create_commit(repo, 'Initial commit') + repo.free() + return commit_id + + repo.free() + return None + + +def get_backend_repo(repo_path): + """Get a backend repository instance for a path.""" + from picard.plugin3.git_factory import git_backend + + return git_backend().create_repository(repo_path) + + +def backend_create_tag(repo_path, tag_name, commit_id=None, message=""): + """Create a tag using the git backend.""" + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + repo = backend.create_repository(repo_path) + if commit_id is None: + commit_id = repo.get_head_target() + backend.create_tag(repo, tag_name, commit_id, message) + repo.free() + + +def backend_create_lightweight_tag(repo_path, tag_name, commit_id=None): + """Create a lightweight tag (reference only) using backend.""" + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + repo = backend.create_repository(repo_path) + if commit_id is None: + commit_id = repo.get_head_target() + backend.create_reference(repo, f'refs/tags/{tag_name}', commit_id) + repo.free() + + +def backend_set_detached_head(repo_path, commit_id): + """Set repository to detached HEAD state using backend.""" + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + repo = backend.create_repository(repo_path) + backend.set_head_detached(repo, commit_id) + repo.free() + + +def backend_create_branch(repo_path, branch_name, commit_id=None): + """Create a branch using the git backend.""" + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + repo = backend.create_repository(repo_path) + if commit_id is None: + commit_id = repo.get_head_target() + backend.create_branch(repo, branch_name, commit_id) + repo.free() + + +def backend_add_and_commit(repo_path, message="Commit", author_name="Test", author_email="test@example.com"): + """Add all files and commit using backend.""" + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + repo = backend.create_repository(repo_path) + commit_id = backend.add_and_commit_files(repo, message, author_name, author_email) + repo.free() + return commit_id + + +def backend_init_and_commit(repo_path, files=None, message="Initial commit"): + """Initialize repo and create initial commit using backend.""" + from pathlib import Path + + from picard.plugin3.git_factory import git_backend + + repo_path = Path(repo_path) + repo_path.mkdir(parents=True, exist_ok=True) + + # Create files first + if files: + for filename, content in files.items(): + (repo_path / filename).write_text(content) + + backend = git_backend() + repo = backend.init_repository(repo_path) + commit_id = backend.add_and_commit_files(repo, message) + repo.free() + return commit_id + + def create_test_manifest_content( name='Test Plugin', version='1.0.0', @@ -343,18 +477,14 @@ def create_test_plugin_dir(base_dir, plugin_name='test-plugin', manifest_content # Initialize git if requested if add_git: try: - import pygit2 - - repo = pygit2.init_repository(str(plugin_dir)) - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature('Test', 'test@example.com') - repo.create_commit('refs/heads/main', author, author, 'Initial commit', tree, []) - repo.set_head('refs/heads/main') - except ImportError: - # pygit2 not available, skip git init + from picard.plugin3.git_factory import has_git_backend + + if has_git_backend(): + create_git_repo_with_backend( + plugin_dir, {'MANIFEST.toml': manifest_content, '__init__.py': '# Test plugin\n'} + ) + except (ImportError, Exception): + # Git backend not available, skip git init pass return plugin_dir @@ -373,16 +503,15 @@ def create_git_commit(repo_path, message='Initial commit', author_name='Test', a str: Commit ID (SHA) """ try: - import pygit2 - - repo = pygit2.Repository(str(repo_path)) - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature(author_name, author_email) - commit_id = repo.create_commit('refs/heads/main', author, author, message, tree, []) - repo.set_head('refs/heads/main') - return str(commit_id) - except ImportError: + from picard.plugin3.git_factory import git_backend, has_git_backend + + if not has_git_backend(): + return None + + backend = git_backend() + repo = backend.create_repository(repo_path) + commit_id = backend.create_commit(repo, message, author_name, author_email) + repo.free() + return commit_id + except (ImportError, Exception): return None diff --git a/test/test_plugins3_manager.py b/test/test_plugins3_manager.py index 948e551a63..25269fb387 100644 --- a/test/test_plugins3_manager.py +++ b/test/test_plugins3_manager.py @@ -167,24 +167,26 @@ def test_check_dirty_working_dir_clean(self): import tempfile try: - import pygit2 + from picard.plugin3.git_factory import has_git_backend + + if not has_git_backend(): + self.skipTest("git backend not available") except ImportError: - self.skipTest("pygit2 not available") + self.skipTest("git backend not available") with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) - repo = pygit2.init_repository(str(repo_dir)) - - # Create and commit a file - (repo_dir / 'test.txt').write_text('test') - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - commit_id = repo.create_commit('refs/heads/main', author, author, 'Initial', tree, []) - repo.set_head('refs/heads/main') - repo.reset(commit_id, pygit2.enums.ResetMode.HARD) + from test.test_plugins3_helpers import backend_init_and_commit + + commit_id = backend_init_and_commit(repo_dir, {'test.txt': 'test'}, 'Initial') + + # Reset to clean state + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + repo = backend.create_repository(repo_dir) + backend.reset_hard(repo, commit_id) + repo.free() changes = GitOperations.check_dirty_working_dir(repo_dir) @@ -196,22 +198,18 @@ def test_check_dirty_working_dir_dirty(self): import tempfile try: - import pygit2 + from picard.plugin3.git_factory import has_git_backend + + if not has_git_backend(): + self.skipTest("git backend not available") except ImportError: - self.skipTest("pygit2 not available") + self.skipTest("git backend not available") with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) - repo = pygit2.init_repository(str(repo_dir)) + from test.test_plugins3_helpers import backend_init_and_commit - # Create and commit a file - (repo_dir / 'test.txt').write_text('test') - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - repo.create_commit('refs/heads/main', author, author, 'Initial', tree, []) + backend_init_and_commit(repo_dir, {'test.txt': 'test'}, 'Initial') # Modify the file (uncommitted change) (repo_dir / 'test.txt').write_text('modified') @@ -277,13 +275,18 @@ def test_update_plugin_dirty_with_discard(self): mock_source_instance.update = Mock(return_value=('old123', 'new456')) mock_source.return_value = mock_source_instance - # Mock pygit2 Repository - with patch('pygit2.Repository') as mock_repo_class: + # Mock git backend at import location + with patch('picard.plugin3.git_factory.git_backend') as mock_backend_func: + mock_backend = Mock() mock_repo = Mock() mock_commit = Mock() - mock_commit.commit_time = 1234567890 - mock_repo.get = Mock(return_value=mock_commit) - mock_repo_class.return_value = mock_repo + mock_commit.id = 'new456' + mock_commit.type = Mock() # Not a tag + mock_repo.revparse_single = Mock(return_value=mock_commit) + mock_repo.get_commit_date = Mock(return_value=1234567890) + mock_repo.free = Mock() + mock_backend.create_repository = Mock(return_value=mock_repo) + mock_backend_func.return_value = mock_backend # Should not raise with discard_changes=True result = manager.update_plugin(mock_plugin, discard_changes=True) @@ -317,9 +320,12 @@ def test_install_plugin_reinstall_dirty_check(self): import tempfile try: - import pygit2 + from picard.plugin3.git_factory import has_git_backend + + if not has_git_backend(): + self.skipTest("git backend not available") except ImportError: - self.skipTest("pygit2 not available") + self.skipTest("git backend not available") with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = Path(tmpdir) @@ -328,17 +334,17 @@ def test_install_plugin_reinstall_dirty_check(self): # Create existing plugin with uncommitted changes existing = plugin_dir / 'test_plugin_uuid' - existing.mkdir() - repo = pygit2.init_repository(str(existing)) - (existing / 'test.txt').write_text('test') - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature("Test", "test@example.com") - commit_id = repo.create_commit('refs/heads/main', author, author, 'Initial', tree, []) - repo.set_head('refs/heads/main') - repo.reset(commit_id, pygit2.enums.ResetMode.HARD) + from test.test_plugins3_helpers import backend_init_and_commit + + commit_id = backend_init_and_commit(existing, {'test.txt': 'test'}, 'Initial') + + # Reset to clean state + from picard.plugin3.git_factory import git_backend + + backend = git_backend() + repo = backend.create_repository(existing) + backend.reset_hard(repo, commit_id) + repo.free() # Modify file (uncommitted change) (existing / 'test.txt').write_text('modified') diff --git a/test/test_plugins3_plugin_coverage.py b/test/test_plugins3_plugin_coverage.py index 8ddad6962a..bf24a03d54 100644 --- a/test/test_plugins3_plugin_coverage.py +++ b/test/test_plugins3_plugin_coverage.py @@ -23,8 +23,8 @@ from test.picardtestcase import PicardTestCase +from picard.plugin3.git_factory import has_git_backend from picard.plugin3.plugin import ( - HAS_PYGIT2, PluginSource, PluginSourceGit, ) @@ -42,8 +42,8 @@ def test_plugin_source_base_not_implemented(self): class TestPluginSourceGitInit(PicardTestCase): def test_plugin_source_git_init_with_ref(self): """Test PluginSourceGit initialization with ref.""" - if not HAS_PYGIT2: - self.skipTest('pygit2 not available') + if not has_git_backend(): + self.skipTest('git backend not available') source = PluginSourceGit('https://example.com/repo.git', ref='v1.0') @@ -53,8 +53,8 @@ def test_plugin_source_git_init_with_ref(self): def test_plugin_source_git_init_without_ref(self): """Test PluginSourceGit initialization without ref.""" - if not HAS_PYGIT2: - self.skipTest('pygit2 not available') + if not has_git_backend(): + self.skipTest('git backend not available') source = PluginSourceGit('https://example.com/repo.git') @@ -65,53 +65,42 @@ def test_plugin_source_git_init_without_ref(self): class TestGitRemoteCallbacks(PicardTestCase): def test_git_remote_callbacks_transfer_progress(self): - """Test GitRemoteCallbacks.transfer_progress() prints progress.""" - if not HAS_PYGIT2: - self.skipTest('pygit2 not available') + """Test GitRemoteCallbacks._transfer_progress() prints progress.""" + if not has_git_backend(): + self.skipTest('git backend not available') from unittest.mock import patch - from picard.plugin3.plugin import GitRemoteCallbacks + from picard.plugin3.git_factory import git_backend - callbacks = GitRemoteCallbacks() + backend = git_backend() + callbacks = backend.create_remote_callbacks() mock_stats = Mock() mock_stats.indexed_objects = 50 mock_stats.total_objects = 100 # Progress output is suppressed for cleaner CLI with patch('builtins.print') as mock_print: - callbacks.transfer_progress(mock_stats) + callbacks._transfer_progress(mock_stats) mock_print.assert_not_called() class TestPluginSourceGitUpdate(PicardTestCase): def test_update_without_ref_uses_head(self): """Test update without ref uses HEAD.""" - if not HAS_PYGIT2: - self.skipTest('pygit2 not available') + if not has_git_backend(): + self.skipTest('git backend not available') import tempfile from picard.plugin3.plugin import PluginSourceGit - import pygit2 - # Create a test git repo with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) / 'source' - repo_dir.mkdir() - - # Initialize repo - repo = pygit2.init_repository(str(repo_dir)) - (repo_dir / 'file.txt').write_text('content') + from test.test_plugins3_helpers import backend_init_and_commit - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature('Test', 'test@example.com') - repo.create_commit('refs/heads/main', author, author, 'Initial', tree, []) - repo.set_head('refs/heads/main') + backend_init_and_commit(repo_dir, {'file.txt': 'content'}, 'Initial') # Clone it target = Path(tmpdir) / 'target' @@ -128,34 +117,22 @@ def test_update_without_ref_uses_head(self): def test_update_with_tag_ref(self): """Test update with tag ref falls back to original ref.""" - if not HAS_PYGIT2: - self.skipTest('pygit2 not available') + if not has_git_backend(): + self.skipTest('git backend not available') import tempfile from picard.plugin3.plugin import PluginSourceGit - import pygit2 - # Create a test git repo with a tag with tempfile.TemporaryDirectory() as tmpdir: repo_dir = Path(tmpdir) / 'source' - repo_dir.mkdir() - - # Initialize repo - repo = pygit2.init_repository(str(repo_dir)) - (repo_dir / 'file.txt').write_text('content') + from test.test_plugins3_helpers import backend_create_tag, backend_init_and_commit - index = repo.index - index.add_all() - index.write() - tree = index.write_tree() - author = pygit2.Signature('Test', 'test@example.com') - commit = repo.create_commit('refs/heads/main', author, author, 'Initial', tree, []) - repo.set_head('refs/heads/main') + commit = backend_init_and_commit(repo_dir, {'file.txt': 'content'}, 'Initial') # Create a tag - repo.create_tag('v1.0', commit, pygit2.GIT_OBJECT_COMMIT, author, 'Version 1.0') + backend_create_tag(repo_dir, 'v1.0', commit, 'Version 1.0') # Clone it target = Path(tmpdir) / 'target' diff --git a/test/test_plugins3_plugin_source.py b/test/test_plugins3_plugin_source.py index efbb0f06d7..aac1450190 100644 --- a/test/test_plugins3_plugin_source.py +++ b/test/test_plugins3_plugin_source.py @@ -20,36 +20,31 @@ from test.picardtestcase import PicardTestCase +from picard.plugin3.git_factory import has_git_backend from picard.plugin3.plugin import ( - HAS_PYGIT2, + PluginSourceGit, PluginSourceSyncError, ) class TestPluginSourceGit(PicardTestCase): - def test_plugin_source_git_without_pygit2(self): - """Test PluginSourceGit raises error when pygit2 not available.""" - if HAS_PYGIT2: - self.skipTest('pygit2 is available') - - from picard.plugin3.plugin import PluginSourceGit + def test_plugin_source_git_without_backend(self): + """Test PluginSourceGit raises error when git backend not available.""" + if has_git_backend(): + self.skipTest('git backend is available') with self.assertRaises(PluginSourceSyncError) as context: PluginSourceGit('https://example.com/repo.git') - self.assertIn('pygit2 is not available', str(context.exception)) + self.assertIn('git backend is not available', str(context.exception)) def test_plugin_source_git_retry_on_network_error(self): """Test PluginSourceGit retries on network errors.""" - if not HAS_PYGIT2: - self.skipTest('pygit2 is not available') + if not has_git_backend(): + self.skipTest('git backend is not available') from unittest.mock import patch - from picard.plugin3.plugin import PluginSourceGit - - import pygit2 - source = PluginSourceGit('https://example.com/repo.git') # Mock operation that fails twice then succeeds @@ -59,7 +54,9 @@ def mock_operation(): nonlocal call_count call_count += 1 if call_count < 3: - raise pygit2.GitError('Failed to resolve host') + from picard.plugin3 import GitBackendError + + raise GitBackendError('Failed to resolve host') return 'success' with patch('picard.plugin3.plugin.time.sleep'): # Skip actual sleep @@ -70,23 +67,21 @@ def mock_operation(): def test_plugin_source_git_no_retry_on_non_network_error(self): """Test PluginSourceGit does not retry on non-network errors.""" - if not HAS_PYGIT2: - self.skipTest('pygit2 is not available') - - from picard.plugin3.plugin import PluginSourceGit - - import pygit2 + if not has_git_backend(): + self.skipTest('git backend is not available') source = PluginSourceGit('https://example.com/repo.git') call_count = 0 + from picard.plugin3 import GitReferenceError + def mock_operation(): nonlocal call_count call_count += 1 - raise pygit2.GitError('Invalid reference') + raise GitReferenceError('Invalid reference') - with self.assertRaises(pygit2.GitError): + with self.assertRaises(GitReferenceError): source._retry_git_operation(mock_operation) # Should only try once (no retries for non-network errors) diff --git a/test/test_plugins3_registry.py b/test/test_plugins3_registry.py index c9bc1e3a4c..d7af3f2569 100644 --- a/test/test_plugins3_registry.py +++ b/test/test_plugins3_registry.py @@ -194,12 +194,13 @@ def test_update_plugin_follows_redirect(self): ) as mock_check_dirty: mock_check_dirty.return_value = [] # No uncommitted changes - with patch('pygit2.Repository') as mock_repo_class: + with patch('picard.plugin3.git_factory.git_backend') as mock_backend_func: + mock_backend = Mock() mock_repo = Mock() - mock_commit = Mock() - mock_commit.commit_time = 1234567890 - mock_repo.get = Mock(return_value=mock_commit) - mock_repo_class.return_value = mock_repo + mock_repo.get_commit_date = Mock(return_value=1234567890) + mock_repo.free = Mock() + mock_backend.create_repository = Mock(return_value=mock_repo) + mock_backend_func.return_value = mock_backend # Update plugin manager.update_plugin(mock_plugin)