Skip to content

Commit f56dd11

Browse files
authored
Merge pull request #2806 from zas/wrap_pygit2
Abstract git backend (wrap pygit2)
2 parents ec978e0 + 3be9ef0 commit f56dd11

25 files changed

+1475
-492
lines changed

picard/debug_opts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,4 @@ class DebugOpt(DebugOptEnum):
8383
PLUGIN_FULLPATH = 1, N_('Plugin Fullpath'), N_('Log plugin full paths')
8484
WS_POST = 2, N_('Web Service Post Data'), N_('Log data of web service post requests')
8585
WS_REPLIES = 3, N_('Web Service Replies'), N_('Log content of web service replies')
86+
GIT_BACKEND = 4, N_('Git Backend'), N_('Log git backend method calls')

picard/plugin3/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Picard, the next-generation MusicBrainz tagger
44
#
5-
# Copyright (C) 2024 Philipp Wolfer
5+
# Copyright (C) 2025 Laurent Monin, Philipp Wolfer
66
#
77
# This program is free software; you can redistribute it and/or
88
# modify it under the terms of the GNU General Public License
@@ -19,6 +19,18 @@
1919
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
2020

2121
from picard.plugin3.api import PluginApi
22+
from picard.plugin3.git_backend import (
23+
GitBackendError,
24+
GitCommitError,
25+
GitReferenceError,
26+
GitRepositoryError,
27+
)
2228

2329

24-
__all__ = ['PluginApi']
30+
__all__ = [
31+
'PluginApi',
32+
'GitBackendError',
33+
'GitRepositoryError',
34+
'GitReferenceError',
35+
'GitCommitError',
36+
]

picard/plugin3/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,7 +1202,7 @@ def _cmd_clean_config(self, plugin_identifier):
12021202
config.endGroup()
12031203

12041204
if not has_config:
1205-
self._out.error(f'No saved options found for "{display_name}"')
1205+
self._out.print(f'No saved options found for "{display_name}"')
12061206

12071207
# Show orphaned configs
12081208
orphaned = self._manager.get_orphaned_plugin_configs()
@@ -1213,7 +1213,7 @@ def _cmd_clean_config(self, plugin_identifier):
12131213
self._out.print(f' • {self._out.d_uuid(uuid)}')
12141214
self._out.nl()
12151215
self._out.print(f'Clean with: {self._out.d_command("picard plugins --clean-config <uuid>")}')
1216-
return ExitCode.NOT_FOUND
1216+
return ExitCode.SUCCESS
12171217

12181218
if not yes:
12191219
if not self._out.yesno(f'Delete saved options for "{display_name}"?'):

picard/plugin3/git_backend.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Picard, the next-generation MusicBrainz tagger
4+
#
5+
# Copyright (C) 2025 Philipp Wolfer, Laurent Monin
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License
9+
# as published by the Free Software Foundation; either version 2
10+
# of the License, or (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program; if not, write to the Free Software
19+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20+
21+
"""Git backend abstraction layer."""
22+
23+
from abc import (
24+
ABC,
25+
abstractmethod,
26+
)
27+
from enum import Enum
28+
from pathlib import Path
29+
from typing import (
30+
Any,
31+
Dict,
32+
List,
33+
Optional,
34+
)
35+
36+
from picard import log
37+
from picard.debug_opts import DebugOpt
38+
39+
40+
class GitBackendError(Exception):
41+
"""Base exception for git backend operations"""
42+
43+
44+
class GitRepositoryError(GitBackendError):
45+
"""Exception for repository-related errors"""
46+
47+
48+
class GitReferenceError(GitBackendError):
49+
"""Exception for reference-related errors"""
50+
51+
52+
class GitCommitError(GitBackendError):
53+
"""Exception for commit-related errors"""
54+
55+
56+
class GitObjectType(Enum):
57+
COMMIT = "commit"
58+
TAG = "tag"
59+
60+
61+
class GitStatusFlag(Enum):
62+
CURRENT = 0
63+
IGNORED = 1
64+
MODIFIED = 2
65+
66+
67+
class GitResetMode(Enum):
68+
HARD = "hard"
69+
70+
71+
class GitCredentialType(Enum):
72+
SSH_KEY = 1
73+
USERPASS = 2
74+
75+
76+
class GitRef:
77+
def __init__(self, name: str, target: str = None):
78+
self.name = name
79+
self.target = target
80+
81+
82+
class GitObject:
83+
def __init__(self, id: str, obj_type: GitObjectType):
84+
self.id = id
85+
self.type = obj_type
86+
87+
88+
class GitRemoteCallbacks:
89+
"""Abstract remote callbacks for authentication"""
90+
91+
92+
def _log_git_call(method_name: str, *args, **kwargs):
93+
"""Log git backend method calls if debug option enabled"""
94+
if DebugOpt.GIT_BACKEND.enabled:
95+
args_str = ', '.join(str(arg)[:100] for arg in args) # Truncate long args
96+
kwargs_str = ', '.join(f'{k}={str(v)[:50]}' for k, v in kwargs.items())
97+
all_args = ', '.join(filter(None, [args_str, kwargs_str]))
98+
log.debug("Git backend call: %s(%s)", method_name, all_args)
99+
100+
101+
class GitRepository(ABC):
102+
"""Abstract interface for repository operations"""
103+
104+
@abstractmethod
105+
def get_status(self) -> Dict[str, GitStatusFlag]:
106+
"""Get working directory status"""
107+
108+
@abstractmethod
109+
def get_head_target(self) -> str:
110+
"""Get HEAD commit ID"""
111+
112+
@abstractmethod
113+
def is_head_detached(self) -> bool:
114+
"""Check if HEAD is detached"""
115+
116+
@abstractmethod
117+
def get_head_shorthand(self) -> str:
118+
"""Get current branch name or short commit"""
119+
120+
@abstractmethod
121+
def get_head_name(self) -> str:
122+
"""Get HEAD reference name"""
123+
124+
@abstractmethod
125+
def revparse_single(self, ref: str) -> GitObject:
126+
"""Resolve reference to object"""
127+
128+
@abstractmethod
129+
def peel_to_commit(self, obj: GitObject) -> GitObject:
130+
"""Peel tag to underlying commit"""
131+
132+
@abstractmethod
133+
def reset(self, commit_id: str, mode: GitResetMode):
134+
"""Reset repository to commit"""
135+
136+
@abstractmethod
137+
def checkout_tree(self, obj: GitObject):
138+
"""Checkout tree object"""
139+
140+
@abstractmethod
141+
def set_head(self, target: str):
142+
"""Set HEAD to target"""
143+
144+
@abstractmethod
145+
def list_references(self) -> List[str]:
146+
"""List all references"""
147+
148+
@abstractmethod
149+
def get_references(self) -> List[str]:
150+
"""Get list of reference names"""
151+
152+
@abstractmethod
153+
def get_remotes(self) -> List[Any]:
154+
"""Get remotes list"""
155+
156+
@abstractmethod
157+
def get_remote(self, name: str) -> Any:
158+
"""Get specific remote by name"""
159+
160+
@abstractmethod
161+
def create_remote(self, name: str, url: str) -> Any:
162+
"""Create remote"""
163+
164+
@abstractmethod
165+
def get_branches(self) -> Any:
166+
"""Get branches object"""
167+
168+
@abstractmethod
169+
def get_commit_date(self, commit_id: str) -> int:
170+
"""Get commit timestamp for given commit ID"""
171+
172+
@abstractmethod
173+
def fetch_remote(self, remote, refspec: str = None, callbacks=None):
174+
"""Fetch from remote with optional refspec"""
175+
176+
@abstractmethod
177+
def free(self):
178+
"""Free repository resources"""
179+
180+
181+
class GitBackend(ABC):
182+
"""Abstract interface for git operations"""
183+
184+
@abstractmethod
185+
def create_repository(self, path: Path) -> GitRepository:
186+
"""Open existing repository"""
187+
188+
@abstractmethod
189+
def init_repository(self, path: Path, bare: bool = False) -> GitRepository:
190+
"""Initialize new repository"""
191+
192+
@abstractmethod
193+
def create_commit(
194+
self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "[email protected]"
195+
) -> str:
196+
"""Create a commit with all staged files"""
197+
198+
@abstractmethod
199+
def create_tag(
200+
self,
201+
repo: GitRepository,
202+
tag_name: str,
203+
commit_id: str,
204+
message: str = "",
205+
author_name: str = "Test",
206+
author_email: str = "[email protected]",
207+
):
208+
"""Create a tag pointing to a commit"""
209+
210+
@abstractmethod
211+
def create_branch(self, repo: GitRepository, branch_name: str, commit_id: str):
212+
"""Create a branch pointing to a commit"""
213+
214+
@abstractmethod
215+
def add_and_commit_files(
216+
self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "[email protected]"
217+
) -> str:
218+
"""Add all files and create commit"""
219+
220+
@abstractmethod
221+
def reset_hard(self, repo: GitRepository, commit_id: str):
222+
"""Reset repository to commit (hard reset)"""
223+
224+
@abstractmethod
225+
def create_reference(self, repo: GitRepository, ref_name: str, commit_id: str):
226+
"""Create a reference pointing to commit"""
227+
228+
@abstractmethod
229+
def set_head_detached(self, repo: GitRepository, commit_id: str):
230+
"""Set HEAD to detached state at commit"""
231+
232+
@abstractmethod
233+
def clone_repository(self, url: str, path: Path, **options) -> GitRepository:
234+
"""Clone repository from URL"""
235+
236+
@abstractmethod
237+
def fetch_remote_refs(self, url: str, **options) -> Optional[List[GitRef]]:
238+
"""Fetch remote refs without cloning"""
239+
240+
@abstractmethod
241+
def create_remote_callbacks(self) -> GitRemoteCallbacks:
242+
"""Create remote callbacks for authentication"""

picard/plugin3/git_factory.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Picard, the next-generation MusicBrainz tagger
4+
#
5+
# Copyright (C) 2025 Philipp Wolfer, Laurent Monin
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License
9+
# as published by the Free Software Foundation; either version 2
10+
# of the License, or (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program; if not, write to the Free Software
19+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20+
21+
"""Git backend factory."""
22+
23+
from picard.plugin3.git_backend import GitBackend
24+
25+
26+
def get_git_backend() -> GitBackend:
27+
"""Get the configured git backend.
28+
29+
Currently only supports pygit2 backend.
30+
Future backends (CLI, dulwich) can be added here.
31+
"""
32+
from picard.plugin3.pygit2_backend import Pygit2Backend
33+
34+
return Pygit2Backend()
35+
36+
37+
def has_git_backend() -> bool:
38+
"""Check if a git backend is available."""
39+
from picard.plugin3.pygit2_backend import HAS_PYGIT2
40+
41+
return HAS_PYGIT2
42+
43+
44+
# Global backend instance
45+
_git_backend = None
46+
47+
48+
def git_backend() -> GitBackend:
49+
"""Get singleton git backend instance"""
50+
global _git_backend
51+
if _git_backend is None:
52+
_git_backend = get_git_backend()
53+
return _git_backend
54+
55+
56+
def _reset_git_backend():
57+
"""Reset the global git backend instance. Used for testing."""
58+
global _git_backend
59+
_git_backend = None

0 commit comments

Comments
 (0)