Skip to content

Commit 324bfd8

Browse files
committed
Complete git backend abstraction and optimize remote fetching
Since previous commit: - Implement complete git backend abstraction layer (GitBackend, Pygit2Backend) - Replace all direct pygit2 usage with backend interface throughout codebase - Optimize remote fetching to target 'origin' remote specifically instead of all remotes - Migrate all tests to use git backend helper functions for consistency - Fix commit.oid -> commit.id for pygit2 API compatibility - Add proper error handling with GitBackendError hierarchy - Simplify remote fetching logic by removing unnecessary fallbacks - Add git backend singleton reset between tests for proper isolation New debug option: - Add GIT_BACKEND debug flag to log git backend method calls for troubleshooting
1 parent 983e908 commit 324bfd8

21 files changed

+1479
-478
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/git_backend.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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+
pass
44+
45+
46+
class GitRepositoryError(GitBackendError):
47+
"""Exception for repository-related errors"""
48+
49+
pass
50+
51+
52+
class GitReferenceError(GitBackendError):
53+
"""Exception for reference-related errors"""
54+
55+
pass
56+
57+
58+
class GitCommitError(GitBackendError):
59+
"""Exception for commit-related errors"""
60+
61+
pass
62+
63+
64+
class GitObjectType(Enum):
65+
COMMIT = "commit"
66+
TAG = "tag"
67+
68+
69+
class GitStatusFlag(Enum):
70+
CURRENT = 0
71+
IGNORED = 1
72+
MODIFIED = 2
73+
74+
75+
class GitResetMode(Enum):
76+
HARD = "hard"
77+
78+
79+
class GitCredentialType(Enum):
80+
SSH_KEY = 1
81+
USERPASS = 2
82+
83+
84+
class GitRef:
85+
def __init__(self, name: str, target: str = None):
86+
self.name = name
87+
self.target = target
88+
89+
90+
class GitObject:
91+
def __init__(self, id: str, obj_type: GitObjectType):
92+
self.id = id
93+
self.type = obj_type
94+
95+
96+
class GitRemoteCallbacks:
97+
"""Abstract remote callbacks for authentication"""
98+
99+
pass
100+
101+
102+
def _log_git_call(method_name: str, *args, **kwargs):
103+
"""Log git backend method calls if debug option enabled"""
104+
if DebugOpt.GIT_BACKEND.enabled:
105+
args_str = ', '.join(str(arg)[:100] for arg in args) # Truncate long args
106+
kwargs_str = ', '.join(f'{k}={str(v)[:50]}' for k, v in kwargs.items())
107+
all_args = ', '.join(filter(None, [args_str, kwargs_str]))
108+
log.debug("Git backend call: %s(%s)", method_name, all_args)
109+
110+
111+
class GitRepository(ABC):
112+
"""Abstract interface for repository operations"""
113+
114+
@abstractmethod
115+
def get_status(self) -> Dict[str, GitStatusFlag]:
116+
"""Get working directory status"""
117+
pass
118+
119+
@abstractmethod
120+
def get_head_target(self) -> str:
121+
"""Get HEAD commit ID"""
122+
pass
123+
124+
@abstractmethod
125+
def is_head_detached(self) -> bool:
126+
"""Check if HEAD is detached"""
127+
pass
128+
129+
@abstractmethod
130+
def get_head_shorthand(self) -> str:
131+
"""Get current branch name or short commit"""
132+
pass
133+
134+
@abstractmethod
135+
def get_head_name(self) -> str:
136+
"""Get HEAD reference name"""
137+
pass
138+
139+
@abstractmethod
140+
def revparse_single(self, ref: str) -> GitObject:
141+
"""Resolve reference to object"""
142+
pass
143+
144+
@abstractmethod
145+
def peel_to_commit(self, obj: GitObject) -> GitObject:
146+
"""Peel tag to underlying commit"""
147+
pass
148+
149+
@abstractmethod
150+
def reset(self, commit_id: str, mode: GitResetMode):
151+
"""Reset repository to commit"""
152+
pass
153+
154+
@abstractmethod
155+
def checkout_tree(self, obj: GitObject):
156+
"""Checkout tree object"""
157+
pass
158+
159+
@abstractmethod
160+
def set_head(self, target: str):
161+
"""Set HEAD to target"""
162+
pass
163+
164+
@abstractmethod
165+
def list_references(self) -> List[str]:
166+
"""List all references"""
167+
pass
168+
169+
@abstractmethod
170+
def get_references(self) -> List[str]:
171+
"""Get list of reference names"""
172+
pass
173+
174+
@abstractmethod
175+
def get_remotes(self) -> List[Any]:
176+
"""Get remotes list"""
177+
pass
178+
179+
@abstractmethod
180+
def get_remote(self, name: str) -> Any:
181+
"""Get specific remote by name"""
182+
pass
183+
184+
@abstractmethod
185+
def create_remote(self, name: str, url: str) -> Any:
186+
"""Create remote"""
187+
pass
188+
189+
@abstractmethod
190+
def get_branches(self) -> Any:
191+
"""Get branches object"""
192+
pass
193+
194+
@abstractmethod
195+
def get_commit_date(self, commit_id: str) -> int:
196+
"""Get commit timestamp for given commit ID"""
197+
pass
198+
199+
@abstractmethod
200+
def fetch_remote(self, remote, refspec: str = None, callbacks=None):
201+
"""Fetch from remote with optional refspec"""
202+
pass
203+
204+
@abstractmethod
205+
def free(self):
206+
"""Free repository resources"""
207+
pass
208+
209+
210+
class GitBackend(ABC):
211+
"""Abstract interface for git operations"""
212+
213+
@abstractmethod
214+
def create_repository(self, path: Path) -> GitRepository:
215+
"""Open existing repository"""
216+
pass
217+
218+
@abstractmethod
219+
def init_repository(self, path: Path, bare: bool = False) -> GitRepository:
220+
"""Initialize new repository"""
221+
pass
222+
223+
@abstractmethod
224+
def create_commit(
225+
self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "[email protected]"
226+
) -> str:
227+
"""Create a commit with all staged files"""
228+
pass
229+
230+
@abstractmethod
231+
def create_tag(
232+
self,
233+
repo: GitRepository,
234+
tag_name: str,
235+
commit_id: str,
236+
message: str = "",
237+
author_name: str = "Test",
238+
author_email: str = "[email protected]",
239+
):
240+
"""Create a tag pointing to a commit"""
241+
pass
242+
243+
@abstractmethod
244+
def create_branch(self, repo: GitRepository, branch_name: str, commit_id: str):
245+
"""Create a branch pointing to a commit"""
246+
pass
247+
248+
@abstractmethod
249+
def add_and_commit_files(
250+
self, repo: GitRepository, message: str, author_name: str = "Test", author_email: str = "[email protected]"
251+
) -> str:
252+
"""Add all files and create commit"""
253+
pass
254+
255+
@abstractmethod
256+
def reset_hard(self, repo: GitRepository, commit_id: str):
257+
"""Reset repository to commit (hard reset)"""
258+
pass
259+
260+
@abstractmethod
261+
def create_reference(self, repo: GitRepository, ref_name: str, commit_id: str):
262+
"""Create a reference pointing to commit"""
263+
pass
264+
265+
@abstractmethod
266+
def set_head_detached(self, repo: GitRepository, commit_id: str):
267+
"""Set HEAD to detached state at commit"""
268+
pass
269+
270+
@abstractmethod
271+
def clone_repository(self, url: str, path: Path, **options) -> GitRepository:
272+
"""Clone repository from URL"""
273+
pass
274+
275+
@abstractmethod
276+
def fetch_remote_refs(self, url: str, **options) -> Optional[List[GitRef]]:
277+
"""Fetch remote refs without cloning"""
278+
pass
279+
280+
@abstractmethod
281+
def create_remote_callbacks(self) -> GitRemoteCallbacks:
282+
"""Create remote callbacks for authentication"""
283+
pass

picard/plugin3/git_factory.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
try:
40+
get_git_backend()
41+
return True
42+
except ImportError:
43+
return False
44+
45+
46+
# Global backend instance
47+
_git_backend = None
48+
49+
50+
def git_backend() -> GitBackend:
51+
"""Get singleton git backend instance"""
52+
global _git_backend
53+
if _git_backend is None:
54+
_git_backend = get_git_backend()
55+
return _git_backend
56+
57+
58+
def _reset_git_backend():
59+
"""Reset the global git backend instance. Used for testing."""
60+
global _git_backend
61+
_git_backend = None

0 commit comments

Comments
 (0)