auto-semver-control 1.8.7.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. auto_semver/__init__.py +5 -0
  2. auto_semver/adapters/__init__.py +4 -0
  3. auto_semver/adapters/git/__init__.py +6 -0
  4. auto_semver/adapters/git/base.py +22 -0
  5. auto_semver/adapters/git/constants.py +27 -0
  6. auto_semver/adapters/git/local.py +450 -0
  7. auto_semver/adapters/git/ops.py +17 -0
  8. auto_semver/adapters/git/promote.py +668 -0
  9. auto_semver/adapters/git/release_pr.py +668 -0
  10. auto_semver/adapters/git/verified.py +491 -0
  11. auto_semver/adapters/github/__init__.py +6 -0
  12. auto_semver/adapters/github/event.py +212 -0
  13. auto_semver/cli/__init__.py +6 -0
  14. auto_semver/cli/__main__.py +9 -0
  15. auto_semver/cli/bump.py +448 -0
  16. auto_semver/cli/finalize.py +223 -0
  17. auto_semver/cli/main.py +281 -0
  18. auto_semver/cli/promote.py +211 -0
  19. auto_semver/cli/setup.py +154 -0
  20. auto_semver/cli/utils.py +161 -0
  21. auto_semver/config/__init__.py +48 -0
  22. auto_semver/config/_models/__init__.py +35 -0
  23. auto_semver/config/_models/bump.py +22 -0
  24. auto_semver/config/_models/changelog.py +104 -0
  25. auto_semver/config/_models/commit_group.py +160 -0
  26. auto_semver/config/_models/commit_groups.py +63 -0
  27. auto_semver/config/_models/config.py +247 -0
  28. auto_semver/config/_models/lock_sync.py +48 -0
  29. auto_semver/config/_models/promotion.py +72 -0
  30. auto_semver/config/_models/pull_request.py +186 -0
  31. auto_semver/config/_models/release.py +31 -0
  32. auto_semver/config/config.py +128 -0
  33. auto_semver/config/constants.py +16 -0
  34. auto_semver/core/__init__.py +4 -0
  35. auto_semver/core/changelog/__init__.py +6 -0
  36. auto_semver/core/changelog/manager.py +289 -0
  37. auto_semver/core/commits/__init__.py +7 -0
  38. auto_semver/core/commits/grouper.py +276 -0
  39. auto_semver/core/commits/parser.py +164 -0
  40. auto_semver/core/pr/__init__.py +12 -0
  41. auto_semver/core/pr/builder.py +92 -0
  42. auto_semver/core/pr/github_builder.py +86 -0
  43. auto_semver/core/semver/__init__.py +8 -0
  44. auto_semver/core/semver/lock.py +200 -0
  45. auto_semver/core/semver/updater.py +75 -0
  46. auto_semver/core/semver/version.py +466 -0
  47. auto_semver/lock_sync/__init__.py +40 -0
  48. auto_semver/lock_sync/patch.py +206 -0
  49. auto_semver/lock_sync/pep440.py +56 -0
  50. auto_semver/lock_sync/registry.py +78 -0
  51. auto_semver/lock_sync/runner.py +100 -0
  52. auto_semver/lock_sync/strategies/__init__.py +17 -0
  53. auto_semver/lock_sync/strategies/npm.py +41 -0
  54. auto_semver/lock_sync/strategies/uv.py +41 -0
  55. auto_semver/lock_sync/strategy.py +59 -0
  56. auto_semver/lock_sync/sync.py +178 -0
  57. auto_semver/py.typed +0 -0
  58. auto_semver/setup/__init__.py +4 -0
  59. auto_semver/setup/check.py +93 -0
  60. auto_semver/setup/links.py +120 -0
  61. auto_semver/setup/scaffold.py +119 -0
  62. auto_semver/setup/scaffolds/__init__.py +3 -0
  63. auto_semver/setup/scaffolds/auto-semver.caller.yml +29 -0
  64. auto_semver/setup/scaffolds/auto_semver_config.yml +44 -0
  65. auto_semver/setup/scaffolds/promote.caller.yml +40 -0
  66. auto_semver/templates/__init__.py +26 -0
  67. auto_semver/templates/engine.py +595 -0
  68. auto_semver/templates/types.py +22 -0
  69. auto_semver/templates/utils.py +235 -0
  70. auto_semver_control-1.8.7.dev0.dist-info/METADATA +190 -0
  71. auto_semver_control-1.8.7.dev0.dist-info/RECORD +87 -0
  72. auto_semver_control-1.8.7.dev0.dist-info/WHEEL +5 -0
  73. auto_semver_control-1.8.7.dev0.dist-info/entry_points.txt +2 -0
  74. auto_semver_control-1.8.7.dev0.dist-info/licenses/LICENSE +674 -0
  75. auto_semver_control-1.8.7.dev0.dist-info/top_level.txt +2 -0
  76. runview/__init__.py +111 -0
  77. runview/bridge.py +43 -0
  78. runview/config.py +66 -0
  79. runview/file_sink.py +50 -0
  80. runview/record.py +42 -0
  81. runview/renderers/__init__.py +50 -0
  82. runview/renderers/base.py +76 -0
  83. runview/renderers/github.py +128 -0
  84. runview/renderers/plain.py +122 -0
  85. runview/renderers/rich_live.py +343 -0
  86. runview/reporter.py +178 -0
  87. runview/summary.py +102 -0
@@ -0,0 +1,5 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ __version__ = "0.1.0"
5
+ __author__ = "Guy Erreich"
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """External system adapters (git plumbing, GitHub API)."""
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ from auto_semver.adapters.git.ops import GitOps
5
+
6
+ __all__ = ["GitOps"]
@@ -0,0 +1,22 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """Shared attribute declarations for the GitOps composition."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from git import Repo
12
+ from github import Github
13
+
14
+
15
+ class GitOpsBase:
16
+ """Attribute surface shared by the GitOps composition on ``self``."""
17
+
18
+ repo: Repo
19
+ github_token: str | None
20
+ signed_commits: bool
21
+ _repo_full_name: str
22
+ _github_clients: dict[str, Github]
@@ -0,0 +1,27 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """Shared constants for git adapters."""
5
+
6
+ LEGACY_RELEASE_PREFIX = "release/"
7
+ DEFAULT_RELEASE_PREFIX = "auto-semver/release/"
8
+
9
+ # Git tree modes that GraphQL createCommitOnBranch cannot express (FileAddition
10
+ # always lands as 100644). REST Git Data is required for these.
11
+ _REST_TREE_MODES = frozenset({"100755", "120000"})
12
+ _KNOWN_GIT_FILE_MODES = frozenset({"100644", "100755", "120000", "040000", "160000"})
13
+ _RAW_DIFF_MIN_MODE_FIELDS = 2
14
+
15
+ # GraphQL mutation that GitHub auto-signs (verified) in one request for multiple
16
+ # file changes. Default for ordinary 100644 metadata. When the tree needs
17
+ # 100755/120000 or a dest↔source mode change, use the REST Git Data fallback
18
+ # (#286) which must still produce verification.verified=true.
19
+ _CREATE_COMMIT_ON_BRANCH_MUTATION = """
20
+ mutation($input: CreateCommitOnBranchInput!) {
21
+ createCommitOnBranch(input: $input) {
22
+ commit {
23
+ oid
24
+ }
25
+ }
26
+ }
27
+ """
@@ -0,0 +1,450 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """Local GitPython plumbing."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ from git import Actor, Commit, GitCommandError, Head, Repo
15
+ from git.remote import PushInfo, Remote
16
+
17
+ from auto_semver.adapters.git.base import GitOpsBase
18
+
19
+ if TYPE_CHECKING:
20
+ pass
21
+
22
+ logger = logging.getLogger(__package__)
23
+
24
+
25
+ class GitLocal(GitOpsBase):
26
+ """Local GitPython plumbing."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ repo_path: str = ".",
32
+ ensure_safe: bool = False,
33
+ signed_commits: bool = False,
34
+ github_token: str | None = None,
35
+ ) -> None:
36
+ """
37
+ Initialize GitOps for the given repository path.
38
+
39
+ Args:
40
+ repo_path (str): Path to the local Git repository (default: current directory).
41
+ ensure_safe (bool): If True, ensures the repository is marked as a safe directory in Git config.
42
+ signed_commits (bool): When True, create commits/merges/tags via the GitHub API so
43
+ GitHub signs them as verified (requires ``github_token``).
44
+ github_token (str | None): Token for API-backed commits (App installation token).
45
+
46
+ """
47
+
48
+ self.repo = Repo(path=repo_path)
49
+ self._repo_full_name: str = self._parse_repository_name() # Cache repository name on init
50
+ self.signed_commits = signed_commits
51
+ self.github_token = github_token
52
+ self._github_clients = {}
53
+ if signed_commits and not github_token:
54
+ raise ValueError("github_token is required when signed_commits=True")
55
+ if ensure_safe:
56
+ self.__ensure_git_safe_directory()
57
+ self._identity = self.__ensure_git_identity()
58
+
59
+ def __ensure_git_safe_directory(self) -> None:
60
+ """
61
+ Ensure the repository path is listed as a safe Git directory in the repository config.
62
+
63
+ This is important for CI environments that may require explicitly trusting the repo.
64
+ Uses repository-level config which doesn't require elevated privileges and is scoped to this repo.
65
+ """
66
+
67
+ logger.debug("Ensuring the repository is marked as a safe directory.")
68
+
69
+ safe_key: str = "safe"
70
+ directory_key: str = "directory"
71
+
72
+ path: str = str(self.repo.working_tree_dir)
73
+
74
+ logger.debug(f"Working tree directory: {path}")
75
+
76
+ try:
77
+ git_config = self.repo.config_writer(config_level="global")
78
+
79
+ logger.debug(f"Checking if {path} is in safe directories.")
80
+
81
+ raw_values = git_config.get_values(section=safe_key, option=directory_key, default="")
82
+ safe_dirs: list[str] = [v for v in raw_values if isinstance(v, str)]
83
+
84
+ if path not in safe_dirs:
85
+ logger.debug(f"{path} is not in safe directories.")
86
+ logger.debug(f"Adding {path} to safe directories.")
87
+
88
+ git_config.set_value(section=safe_key, option=directory_key, value=path)
89
+
90
+ git_config.release()
91
+
92
+ except (OSError, PermissionError) as e:
93
+ logger.error(f"Failed to configure git safe directory due to permission error: {e}")
94
+ logger.error(
95
+ "Git safe directory configuration is required for proper operation in CI environments."
96
+ )
97
+ raise RuntimeError(f"Unable to configure git safe directory: {e}") from e
98
+
99
+ @staticmethod
100
+ def _redact_remote_url(url: str) -> str:
101
+ """Strip userinfo (e.g. tokens) from a remote URL before logging or raising."""
102
+ try:
103
+ parts = urlsplit(url)
104
+ if parts.scheme in {"http", "https"} and (parts.username or parts.password):
105
+ host = parts.hostname or ""
106
+ if parts.port:
107
+ host = f"{host}:{parts.port}"
108
+ return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
109
+ except ValueError:
110
+ pass
111
+ # Fallback for non-standard URLs that still embed userinfo before '@'
112
+ return re.sub(r"(https?://)[^/@\s]+@", r"\1", url)
113
+
114
+ def _parse_repository_name(self, *, remote_name: str = "origin") -> str:
115
+ """
116
+ Extract the repository name from the Git remote URL.
117
+
118
+ Args:
119
+ remote_name (str): Name of the Git remote (default: 'origin').
120
+
121
+ Returns:
122
+ str: Repository name in "owner/repo" format.
123
+
124
+ Raises:
125
+ ValueError: If remote URL cannot be parsed or is not a GitHub URL.
126
+ """
127
+ try:
128
+ remote: Remote = self.repo.remote(name=remote_name)
129
+ remote_url = remote.url
130
+
131
+ # Handle both SSH and HTTPS GitHub URLs
132
+ # SSH: git@github.com:owner/repo.git
133
+ # HTTPS: https://github.com/owner/repo.git
134
+
135
+ patterns = [
136
+ r"git@github\.com:([^/]+)/(.+?)(?:\.git)?$", # SSH format
137
+ r"https://(?:.*?@)?github\.com/([^/]+)/(.+?)(?:\.git)?$", # HTTPS format (supports auth)
138
+ ]
139
+
140
+ for pattern in patterns:
141
+ match = re.match(pattern, remote_url)
142
+ if match:
143
+ owner, repo = match.groups()
144
+ return f"{owner}/{repo}"
145
+
146
+ safe_url = self._redact_remote_url(remote_url)
147
+ raise ValueError(f"Unable to parse GitHub repository from remote URL: {safe_url}")
148
+
149
+ except ValueError:
150
+ raise
151
+ except Exception as e:
152
+ raise ValueError(
153
+ f"Failed to get repository name from remote '{remote_name}': {e}"
154
+ ) from e
155
+
156
+ def get_repository_name(self) -> str:
157
+ """
158
+ Get the cached repository name.
159
+
160
+ Returns:
161
+ str: Repository name in "owner/repo" format.
162
+ """
163
+ return self._repo_full_name
164
+
165
+ def create_branch(self, *, branch_name: str, force: bool = False) -> None:
166
+ """
167
+ Create a new Git branch or overwrites an existing one if specified.
168
+
169
+ Args:
170
+ branch_name (str): The name of the branch to create.
171
+ force (bool): If True, overwrites the existing branch with the same name.
172
+ Defaults to False, which prevents overwriting.
173
+
174
+ """
175
+
176
+ if branch_name in self.repo.heads:
177
+ if not force:
178
+ logger.info(f"Branch '{branch_name}' already exists and force is False.")
179
+ return
180
+
181
+ logger.info(f"Deleting existing branch '{branch_name}'")
182
+
183
+ existing_branch: Head = self.repo.heads[branch_name]
184
+ existing_branch.delete(repo=self.repo, force=True)
185
+
186
+ logger.info(f"Creating new branch '{branch_name}'")
187
+
188
+ new_branch: Head = self.repo.create_head(path=branch_name)
189
+ new_branch.checkout()
190
+
191
+ def add(self, files: list[str] | list[Path] | list[str | Path]) -> None:
192
+ """
193
+ Stage the specified files for commit.
194
+
195
+ Args:
196
+ files (list[str]): List of file paths to add to the Git index.
197
+
198
+ """
199
+ files = [str(f) for f in files]
200
+
201
+ logger.info(f"Adding files: {files}")
202
+
203
+ if not self.repo.is_dirty(untracked_files=True):
204
+ logger.warning("Repo is not dirty — no changes staged or committed.")
205
+ else:
206
+ logger.debug("Repo has staged/committed changes.")
207
+
208
+ for file_path in files:
209
+ try:
210
+ self.repo.index.add(items=[file_path])
211
+
212
+ logger.debug(f"Added {file_path} to git.")
213
+
214
+ except GitCommandError as err:
215
+ logger.error(f"Failed to add {file_path} to git: {err}")
216
+ raise
217
+
218
+ def commit(self, message: str, *, force: bool = False) -> None:
219
+ """
220
+ Commit staged changes with the provided message.
221
+
222
+ Args:
223
+ message (str): Commit message.
224
+ force (bool): Kept for compatibility with signed commits. GraphQL
225
+ ``createCommitOnBranch`` cannot force-update divergent refs;
226
+ concurrent updates retry once on ``expectedHeadOid`` mismatch.
227
+ """
228
+
229
+ logger.info(f"Committing changes with message: {message}")
230
+ logger.debug(f"Staged changes: {self.repo.index.diff('HEAD')}")
231
+
232
+ if self.signed_commits:
233
+ branch_name = self.repo.active_branch.name
234
+ additions, deletions = self._collect_staged_file_changes()
235
+ if not additions and not deletions:
236
+ logger.warning("No staged files for signed commit")
237
+ return
238
+ commit_sha = self._api_commit_files_on_branch(
239
+ branch_name=branch_name,
240
+ message=message,
241
+ file_paths=additions,
242
+ deletions=deletions,
243
+ force=force,
244
+ )
245
+ # GraphQL commits update the remote only; sync the runner worktree so
246
+ # later checkouts (e.g. auto-promote to staging) are not blocked by
247
+ # leftover dirty files such as .semver.lock.
248
+ self.repo.git.reset("--hard", commit_sha)
249
+ logger.info("Synced local worktree to signed commit %s", commit_sha)
250
+ return
251
+
252
+ self._local_commit(message)
253
+ logger.info("Committed changes.")
254
+
255
+ def _local_commit(self, message: str) -> Commit:
256
+ """
257
+ Create a local commit as the bot, bypassing repository hooks.
258
+
259
+ Two deliberate deviations from ``git commit``:
260
+ - ``GIT_AUTHOR_*``/``GIT_COMMITTER_*`` are ignored so bot attribution is
261
+ deterministic; ``user.*`` config is the override channel.
262
+ - Hooks are skipped (``git commit -n``). Lint/commit-msg policy belongs on
263
+ the PR, and the signed GraphQL path cannot run hooks, so skipping keeps
264
+ both paths symmetric.
265
+ """
266
+ return self.repo.index.commit(
267
+ message, author=self._identity, committer=self._identity, skip_hooks=True
268
+ )
269
+
270
+ def push(self, *, branch_name: str, remote_name: str = "origin", force: bool = False) -> None:
271
+ """
272
+ Push the specified branch to the remote repository, optionally forcing the push.
273
+
274
+ Args:
275
+ branch_name (str): The branch to push.
276
+ remote_name (str): Name of the Git remote (default: 'origin').
277
+ force (bool): If True, force push the branch.
278
+
279
+ """
280
+
281
+ logger.info(f"Pushing branch '{branch_name}' to remote '{remote_name}' with force={force}.")
282
+
283
+ if self.signed_commits:
284
+ logger.info(
285
+ "Signed commit path: branch/tag refs updated via GitHub API; syncing local clone"
286
+ )
287
+ self.fetch(remote_name=remote_name)
288
+ return
289
+
290
+ try:
291
+ remote: Remote = self.repo.remote(name=remote_name)
292
+ push_infos = remote.push(refspec=branch_name, force=force)
293
+
294
+ for info in push_infos:
295
+ if info.flags & (
296
+ PushInfo.ERROR
297
+ | PushInfo.REJECTED
298
+ | PushInfo.REMOTE_REJECTED
299
+ | PushInfo.REMOTE_FAILURE
300
+ ):
301
+ error_msg = f"Push failed for {branch_name}: {info.summary}"
302
+ if info.flags & PushInfo.REJECTED:
303
+ error_msg += ". Check if remote has diverged or if 'force' is required."
304
+
305
+ logger.error(error_msg)
306
+ raise RuntimeError(error_msg)
307
+
308
+ logger.debug(f"Push result: {push_infos}")
309
+
310
+ except GitCommandError as err:
311
+ logger.error(f"Failed to push branch '{branch_name}' to remote '{remote_name}': {err}")
312
+ raise
313
+
314
+ def tag(self, *, tag: str, branch: str) -> str:
315
+ """
316
+ Create a new tag on the given branch.
317
+
318
+ Args:
319
+ tag (str): Tag name.
320
+ branch (str): Branch name.
321
+
322
+ """
323
+ if self.signed_commits:
324
+ gh_repo = self._gh_repo()
325
+ branch_ref = gh_repo.get_git_ref(f"heads/{branch}")
326
+ return self._api_create_lightweight_tag(tag=tag, sha=branch_ref.object.sha)
327
+
328
+ return self.repo.create_tag(path=tag, ref=branch, message="").name
329
+
330
+ def fetch(self, *, remote_name: str = "origin") -> None:
331
+ """
332
+ Fetch all refs from the remote repository.
333
+
334
+ Args:
335
+ remote_name (str): Name of the Git remote (default: 'origin').
336
+
337
+ Raises:
338
+ GitCommandError: If fetch operation fails.
339
+ """
340
+ logger.info(f"Fetching from remote '{remote_name}'")
341
+
342
+ try:
343
+ remote: Remote = self.repo.remote(name=remote_name)
344
+ remote.fetch()
345
+ logger.debug(f"Fetch from '{remote_name}' completed")
346
+ except GitCommandError as err:
347
+ logger.error(f"Failed to fetch from remote '{remote_name}': {err}")
348
+ raise
349
+
350
+ def checkout(self, *, branch_name: str, create_from: str | None = None) -> None:
351
+ """
352
+ Checkout an existing branch or create and checkout a new branch.
353
+
354
+ Args:
355
+ branch_name (str): Name of the branch to checkout.
356
+ create_from (str | None): If provided, create the branch from this ref before checkout.
357
+
358
+ Raises:
359
+ GitCommandError: If checkout operation fails.
360
+ """
361
+ try:
362
+ if create_from:
363
+ logger.info(
364
+ f"Creating and checking out branch '{branch_name}' from '{create_from}'"
365
+ )
366
+ new_branch = self.repo.create_head(branch_name, create_from)
367
+ new_branch.checkout()
368
+ else:
369
+ logger.info(f"Checking out branch '{branch_name}'")
370
+ self.repo.heads[branch_name].checkout()
371
+
372
+ logger.debug(f"Checked out branch '{branch_name}'")
373
+ except (GitCommandError, IndexError) as err:
374
+ logger.error(f"Failed to checkout branch '{branch_name}': {err}")
375
+ raise GitCommandError(f"Checkout failed for branch '{branch_name}'") from err
376
+
377
+ def __ensure_git_identity(
378
+ self,
379
+ *,
380
+ email: str = "256984269+auto-semver-bot[bot]@users.noreply.github.com",
381
+ name: str = "auto-semver-bot[bot]",
382
+ ) -> Actor:
383
+ """
384
+ Resolve and cache the bot Actor used for local commits.
385
+
386
+ Prefer an existing ``user.name`` / ``user.email`` from git config (CI
387
+ usually sets these from App token ``user-name`` / ``user-email``
388
+ outputs). Otherwise write the App bot defaults into repo config so
389
+ subprocess git operations (merge, etc.) also attribute correctly.
390
+
391
+ When the config write fails (read-only FS, permissions), still return
392
+ the in-memory defaults so ``_local_commit`` can proceed.
393
+
394
+ Args:
395
+ email: Git user email (default: App bot users.noreply address).
396
+ name: Git user name (default: auto-semver-bot[bot]).
397
+
398
+ Returns:
399
+ Actor to use as both author and committer for local commits.
400
+ """
401
+ defaults = Actor(name=name, email=email)
402
+ try:
403
+ with self.repo.config_reader() as config:
404
+ try:
405
+ existing_email = config.get_value("user", "email")
406
+ existing_name = config.get_value("user", "name")
407
+ logger.debug(
408
+ f"Git identity already configured: {existing_name} <{existing_email}>"
409
+ )
410
+ return Actor(name=str(existing_name), email=str(existing_email))
411
+ except Exception:
412
+ # Not configured, will set below
413
+ pass
414
+
415
+ logger.debug(f"Configuring Git identity: {name} <{email}>")
416
+ with self.repo.config_writer() as config:
417
+ config.set_value("user", "email", email)
418
+ config.set_value("user", "name", name)
419
+
420
+ logger.debug("Git identity configured successfully")
421
+ return defaults
422
+ except Exception as err:
423
+ logger.warning(
424
+ "Failed to configure Git identity: %s; using in-memory defaults %s <%s>",
425
+ err,
426
+ name,
427
+ email,
428
+ )
429
+ return defaults
430
+
431
+ def pull(self, *, branch_name: str, remote_name: str = "origin") -> None:
432
+ """
433
+ Pull the latest changes for the current branch from remote.
434
+
435
+ Args:
436
+ branch_name (str): Name of the branch to pull.
437
+ remote_name (str): Name of the Git remote (default: 'origin').
438
+
439
+ Raises:
440
+ GitCommandError: If pull operation fails.
441
+ """
442
+ logger.info(f"Pulling latest changes for '{branch_name}' from '{remote_name}'")
443
+
444
+ try:
445
+ remote: Remote = self.repo.remote(name=remote_name)
446
+ remote.pull(branch_name)
447
+ logger.debug(f"Pull for '{branch_name}' completed")
448
+ except GitCommandError as err:
449
+ logger.error(f"Failed to pull '{branch_name}' from '{remote_name}': {err}")
450
+ raise
@@ -0,0 +1,17 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """GitOps facade composing local, verified, promote, and release-PR parts."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from auto_semver.adapters.git.local import GitLocal
9
+ from auto_semver.adapters.git.promote import GitPromote
10
+ from auto_semver.adapters.git.release_pr import GitReleasePr
11
+ from auto_semver.adapters.git.verified import GitVerifiedCommits
12
+
13
+
14
+ class GitOps(GitLocal, GitVerifiedCommits, GitPromote, GitReleasePr):
15
+ """Unified Git / GitHub operations for auto-semver pipelines."""
16
+
17
+ pass