Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions picard/debug_opts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
16 changes: 14 additions & 2 deletions picard/plugin3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
]
4 changes: 2 additions & 2 deletions picard/plugin3/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 <uuid>")}')
return ExitCode.NOT_FOUND
return ExitCode.SUCCESS

if not yes:
if not self._out.yesno(f'Delete saved options for "{display_name}"?'):
Expand Down
242 changes: 242 additions & 0 deletions picard/plugin3/git_backend.py
Original file line number Diff line number Diff line change
@@ -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 = "[email protected]"
) -> 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 = "[email protected]",
):
"""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 = "[email protected]"
) -> 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"""
59 changes: 59 additions & 0 deletions picard/plugin3/git_factory.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading