git-project 0.0.37__tar.gz → 0.0.38__tar.gz

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 (61) hide show
  1. {git_project-0.0.37 → git_project-0.0.38}/.gitignore +22 -0
  2. git_project-0.0.38/PKG-INFO +155 -0
  3. {git_project-0.0.37 → git_project-0.0.38}/_version.py +2 -2
  4. git_project-0.0.38/docs/changelog.rst +84 -0
  5. {git_project-0.0.37 → git_project-0.0.38}/docs/intro.rst +1 -1
  6. {git_project-0.0.37 → git_project-0.0.38}/pyproject.toml +41 -9
  7. {git_project-0.0.37 → git_project-0.0.38}/requirements.txt +1 -1
  8. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/commandline.py +1 -5
  9. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/git.py +90 -60
  10. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/pluginmanager.py +1 -5
  11. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/project.py +10 -5
  12. {git_project-0.0.37 → git_project-0.0.38}/tests/test_git.py +147 -0
  13. {git_project-0.0.37 → git_project-0.0.38}/tests/test_project.py +29 -8
  14. git_project-0.0.37/PKG-INFO +0 -78
  15. git_project-0.0.37/build/lib/git_project/__init__.py +0 -33
  16. git_project-0.0.37/build/lib/git_project/commandline.py +0 -164
  17. git_project-0.0.37/build/lib/git_project/configobj.py +0 -300
  18. git_project-0.0.37/build/lib/git_project/exception.py +0 -30
  19. git_project-0.0.37/build/lib/git_project/git.py +0 -873
  20. git_project-0.0.37/build/lib/git_project/gitproject.py +0 -61
  21. git_project-0.0.37/build/lib/git_project/main.py +0 -63
  22. git_project-0.0.37/build/lib/git_project/parsermanager.py +0 -360
  23. git_project-0.0.37/build/lib/git_project/plugin.py +0 -118
  24. git_project-0.0.37/build/lib/git_project/pluginmanager.py +0 -54
  25. git_project-0.0.37/build/lib/git_project/project.py +0 -197
  26. git_project-0.0.37/build/lib/git_project/runnable.py +0 -71
  27. git_project-0.0.37/build/lib/git_project/scopedobj.py +0 -207
  28. git_project-0.0.37/build/lib/git_project/shell.py +0 -119
  29. git_project-0.0.37/build/lib/git_project/substitutable.py +0 -139
  30. git_project-0.0.37/docs/changelog.rst +0 -39
  31. git_project-0.0.37/test.out +0 -18
  32. {git_project-0.0.37 → git_project-0.0.38}/CONTRIBUTORS +0 -0
  33. {git_project-0.0.37 → git_project-0.0.38}/COPYING +0 -0
  34. {git_project-0.0.37 → git_project-0.0.38}/MANIFEST.in +0 -0
  35. {git_project-0.0.37 → git_project-0.0.38}/docs/Makefile +0 -0
  36. {git_project-0.0.37 → git_project-0.0.38}/docs/authors.rst +0 -0
  37. {git_project-0.0.37 → git_project-0.0.38}/docs/conf.py +0 -0
  38. {git_project-0.0.37 → git_project-0.0.38}/docs/index.rst +0 -0
  39. {git_project-0.0.37 → git_project-0.0.38}/docs/license.rst +0 -0
  40. {git_project-0.0.37 → git_project-0.0.38}/docs/make.bat +0 -0
  41. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/__init__.py +0 -0
  42. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/configobj.py +0 -0
  43. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/exception.py +0 -0
  44. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/gitproject.py +0 -0
  45. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/main.py +0 -0
  46. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/parsermanager.py +0 -0
  47. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/plugin.py +0 -0
  48. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/runnable.py +0 -0
  49. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/scopedobj.py +0 -0
  50. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/shell.py +0 -0
  51. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/substitutable.py +0 -0
  52. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/test_support/__init__.py +0 -0
  53. {git_project-0.0.37 → git_project-0.0.38}/src/git_project/test_support/common.py +0 -0
  54. {git_project-0.0.37 → git_project-0.0.38}/tests/conftest.py +0 -0
  55. {git_project-0.0.37 → git_project-0.0.38}/tests/test_confobj.py +0 -0
  56. {git_project-0.0.37 → git_project-0.0.38}/tests/test_gitproject.py +0 -0
  57. {git_project-0.0.37 → git_project-0.0.38}/tests/test_main.py +0 -0
  58. {git_project-0.0.37 → git_project-0.0.38}/tests/test_runnable.py +0 -0
  59. {git_project-0.0.37 → git_project-0.0.38}/tests/test_scopedobj.py +0 -0
  60. {git_project-0.0.37 → git_project-0.0.38}/tests/test_shell.py +0 -0
  61. {git_project-0.0.37 → git_project-0.0.38}/tests/test_substitutable.py +0 -0
@@ -26,3 +26,25 @@
26
26
 
27
27
  # Python egg metadata, regenerated from source files by setuptools.
28
28
  /*.egg-info
29
+
30
+ # Python bytecode cache directories.
31
+ __pycache__/
32
+
33
+ # Version file generated by vcs-versioning.
34
+ /_version.py
35
+
36
+ # Setuptools build folder.
37
+ /build/
38
+
39
+ # Editor backup files.
40
+ *~
41
+
42
+ # Coverage data. The coverage config sets parallel, so each run also writes
43
+ # .coverage.<host>.<pid>.<random> files for coverage combine.
44
+ .coverage*
45
+
46
+ # Coverage HTML report.
47
+ /htmlcov/
48
+
49
+ # Sphinx build output.
50
+ /docs/_build/
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.5
2
+ Name: git-project
3
+ Version: 0.0.38
4
+ Summary: The extensible stupid project manager
5
+ Author-email: "David A. Greene" <dag@obbligato.org>
6
+ License-Expression: AGPL-3.0-or-later
7
+ License-File: COPYING
8
+ Keywords: development,git,project
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Topic :: Software Development :: Version Control :: Git
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: progressbar2>=4.4.2
18
+ Requires-Dist: pygit2>=1.18.2
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest; extra == 'test'
21
+ Requires-Dist: pytest-console-scripts; extra == 'test'
22
+ Requires-Dist: pytest-cov; extra == 'test'
23
+ Requires-Dist: setuptools; extra == 'test'
24
+ Description-Content-Type: text/x-rst
25
+
26
+
27
+ ===================================================
28
+ git-project - The extensible stupid project manager
29
+ ===================================================
30
+
31
+ |VersionImageLink|_
32
+
33
+ |PythonVersionImageLink|_
34
+
35
+ .. |VersionImageLink| image:: https://img.shields.io/pypi/v/git-project.svg
36
+ .. _VersionImageLink: https://pypi.org/project/git-project
37
+ .. |PythonVersionImageLink| image:: https://img.shields.io/pypi/pyversions/git-project.svg
38
+ .. _PythonVersionImageLink: https://pypi.org/project/git-project
39
+
40
+
41
+ -------
42
+
43
+ .. contents:: Table of Contents
44
+
45
+
46
+ Installation
47
+ ============
48
+ ::
49
+
50
+ pip install git-project
51
+ pip install git-project-core-plugins
52
+
53
+ Description
54
+ ===========
55
+
56
+ git-project is a git extension to manage development of a project hosted in a
57
+ git repository. By itself git-project does almost nothing. Its functionality
58
+ is enhanced by plugins.
59
+
60
+ `git-project-core-plugins
61
+ <http://www.github.com/greened/git-project-core-plugins>`_ provides a set of
62
+ basic functionality and should almost always be installed alongside git-project.
63
+
64
+ Conventions
65
+ ===========
66
+
67
+ Symlinks identiy projects to the ``git-project`` command. For example, if
68
+ ``git-fizzbin`` is symlinked to ``git-project``, then ``git fizzbin <command>``
69
+ will invoke ``git-project`` with ``fizzbin`` as the "active project." To
70
+ emphasize this, we show git-project commands with a generic ``<project>``
71
+ identifier::
72
+
73
+ git <project> --help
74
+
75
+ Discussion
76
+ ==========
77
+
78
+ With git-project and its core plugins you can:
79
+
80
+ * Initialize a development environment at clone time (or after clone time)
81
+ * Manage branches
82
+ * Manage worktrees
83
+ * Set and invoke commands
84
+
85
+ git-project is intended to make switching between active 'tasks' in a repository
86
+ simple and fast, without losing the progress context of existing tasks. For
87
+ example the core plugins set up build environments such that switching among
88
+ projects and worktrees does not result in "rebuilding the world." Builds can be
89
+ configured to invoke complex commands via a convenient name (e.g. ``git
90
+ <project> build debug``)
91
+
92
+ Substitution variables
93
+ ======================
94
+
95
+ Commands that allow substitution take a form ``{varname}`` in their configured
96
+ textual representation and substitute it with the value of ``varname``.
97
+ ``varname`` can be any configured value under ``<project>``, for example::
98
+
99
+ [project]
100
+ myvar = value
101
+
102
+ ``git-project`` has several built-in substitution variables that various
103
+ commands and plugins can use:
104
+
105
+ ``branch``
106
+ The name of the currently checked-out branch
107
+ ``gitdir``
108
+ The value of ``GITDIR``
109
+ ``git_common_dir``
110
+ The value of ``GIT_COMMON_DIR``
111
+ ``project``
112
+ The value of ``<project>``
113
+
114
+ License
115
+ =======
116
+ `git-project` is distributed under the terms of the `GNU General Public License v3.0 or later`_.
117
+
118
+ .. _`GNU General Public License v3.0 or later`: https://spdx.org/licenses/GPL-3.0-or-later.html
119
+
120
+
121
+ ..
122
+ SPDX-FileCopyrightText: 2024-present David A. Greene <dag@obbligato.org>
123
+
124
+ ..
125
+ SPDX-License-Identifier: AGPL-3.0-or-later
126
+
127
+ ..
128
+ Copyright 2023 David A. Greene
129
+
130
+ ..
131
+ This file is part of git-project
132
+
133
+ ..
134
+ git-project is free software: you can redistribute it and/or modify it under
135
+ the terms of the GNU Affero General Public License as published by the Free
136
+ Software Foundation, either version 3 of the License, or (at your option)
137
+ any later version.
138
+
139
+ ..
140
+ This program is distributed in the hope that it will be useful, but WITHOUT
141
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
142
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
143
+ more details.
144
+
145
+ ..
146
+ You should have received a copy of the GNU Affero General Public License
147
+ along with git-project. If not, see <https://www.gnu.org/licenses/>.
148
+
149
+ Authors
150
+ =======
151
+ *git-project* is written and maintained by |author|.
152
+
153
+ .. |author| replace:: `David Greene`_
154
+ .. _David Greene: https://github.com/greened
155
+
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.0.37'
22
- __version_tuple__ = version_tuple = (0, 0, 37)
21
+ __version__ = version = '0.0.38'
22
+ __version_tuple__ = version_tuple = (0, 0, 38)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,84 @@
1
+ ..
2
+ SPDX-FileCopyrightText: 2023-present David A. Greene <dag@obbligato.org>
3
+
4
+ ..
5
+ SPDX-License-Identifier: AGPL-3.0-or-later
6
+
7
+ ..
8
+ Copyright 2023 David A. Greene
9
+
10
+ ..
11
+ This file is part of git-project
12
+
13
+ ..
14
+ git-project is free software: you can redistribute it and/or modify it under
15
+ the terms of the GNU Affero General Public License as published by the Free
16
+ Software Foundation, either version 3 of the License, or (at your option)
17
+ any later version.
18
+
19
+ ..
20
+ This program is distributed in the hope that it will be useful, but WITHOUT
21
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
22
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
23
+ more details.
24
+
25
+ ..
26
+ You should have received a copy of the GNU Affero General Public License
27
+ along with git-project. If not, see <https://www.gnu.org/licenses/>.
28
+
29
+ ChangeLog
30
+ =========
31
+ `Unreleased`_
32
+ -------------
33
+
34
+ `0.0.38`_ - 2026-09-24
35
+ ----------------------
36
+ Added
37
+ .....
38
+ - ``prune_branch`` takes a ``keep_remote_branch`` keyword. It previously
39
+ deleted the branch from every project remote as well as locally, so retiring
40
+ a local branch destroyed the remote copy with it.
41
+
42
+ Changed
43
+ .......
44
+ - Python 3.10 or later is now required. ``list_heads`` needs it, and 1.15.1 is
45
+ the last pygit2 that supports 3.9, so on 3.9 there is no installable pygit2
46
+ carrying the API.
47
+ - pygit2 1.18.2 or later is now required, up from 1.15.1. The old range still
48
+ resolved, so the package installed cleanly and then raised ``AttributeError``
49
+ in use.
50
+
51
+ Fixed
52
+ .....
53
+ - ``git <project> clone`` did not work at all. ``Git.clone`` required an ssh ID,
54
+ so the clone paths raised ``TypeError``. The ID is now optional, and a
55
+ keypair is simply not offered when there is none. This also fixes
56
+ ``fetch_remote``, ``remote_branch_exists`` and ``delete_remote_branch`` for
57
+ any repository that never set ``ssh.id``.
58
+ - ``remote_branch_exists`` called ``Remote.ls_remotes()``, which pygit2 1.20
59
+ removed. It now calls ``list_heads()`` and reads the fields off the
60
+ ``RemoteHead`` objects.
61
+ - A ``Git`` re-pointed at a path with no repository stayed attached to the
62
+ previous one. ``has_repo`` answered ``True``, so later work ran against the
63
+ repository the caller had left. It now detaches, and ``has_repo`` answers
64
+ ``False``.
65
+ - A ``Git`` built outside any repository named a missing private field rather
66
+ than the real fault. ``reinit`` set the repository only when discovery
67
+ succeeded, so most accessors raised ``AttributeError``. ``config`` named
68
+ ``_config`` rather than ``_repo``, and ``committish_exists`` raised nothing
69
+ at all and answered ``False``, which said the committish was absent. The
70
+ repository accessors now raise ``GitProjectException`` naming the path that
71
+ was searched.
72
+ - The Issues link on the PyPI project page returned 404. The URL named
73
+ ``unknown/greened`` instead of ``greened/git-project``, a leftover from
74
+ hatch's project template.
75
+ - The PyPI long description stopped at the separator after the badges, so
76
+ everything from Installation on went unpublished. A second readme fragment
77
+ now carries the rest.
78
+ - The Sphinx docs rendered no module documentation at all. ``automodule`` was
79
+ asked for ``git-project``, which is not an importable name; it now asks for
80
+ ``git_project``. No declared environment supplied Sphinx either, so a
81
+ ``docs`` environment with Sphinx is now declared.
82
+
83
+ .. _Unreleased: https://github.com/greened/git-project/compare/v0.0.38...HEAD
84
+ .. _0.0.38: https://github.com/greened/git-project/compare/v0.0.37...v0.0.38
@@ -26,4 +26,4 @@
26
26
  You should have received a copy of the GNU Affero General Public License
27
27
  along with git-project. If not, see <https://www.gnu.org/licenses/>.
28
28
 
29
- .. automodule:: git-project
29
+ .. automodule:: git_project
@@ -31,7 +31,7 @@ name = "git-project"
31
31
  dynamic = ["dependencies", "readme", "version"]
32
32
  description = "The extensible stupid project manager"
33
33
  license = "AGPL-3.0-or-later"
34
- requires-python = ">=3.9"
34
+ requires-python = ">=3.10"
35
35
  authors = [
36
36
  { name = "David A. Greene", email = "dag@obbligato.org" },
37
37
  ]
@@ -45,7 +45,8 @@ classifiers = [
45
45
  "Environment :: Console",
46
46
  "Intended Audience :: Developers",
47
47
  "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
48
- "Programming Language :: Python :: 3.9",
48
+ "Programming Language :: Python :: 3.10",
49
+ "Programming Language :: Python :: 3.11",
49
50
  "Topic :: Software Development :: Version Control :: Git",
50
51
  ]
51
52
 
@@ -62,7 +63,7 @@ git-project = "git_project.main:main"
62
63
 
63
64
  [project.urls]
64
65
  Documentation = "https://github.com/greened/git-project#readme"
65
- Issues = "https://github.com/unknown/greened/issues"
66
+ Issues = "https://github.com/greened/git-project/issues"
66
67
  Source = "https://github.com/greened/git-project"
67
68
 
68
69
  [tool.hatch.version]
@@ -70,6 +71,9 @@ source = "vcs"
70
71
 
71
72
  [tool.hatch.envs.default]
72
73
  dependencies = [
74
+ # Provides the "git-project.plugins" entry points the CLI tests dispatch
75
+ # through; without them argparse offers an empty subcommand list.
76
+ "git-project-core-plugins",
73
77
  "coverage[toml]>=6.5",
74
78
  "pytest",
75
79
  "pytest-console-scripts",
@@ -88,7 +92,7 @@ cov = [
88
92
  ]
89
93
 
90
94
  [[tool.hatch.envs.all.matrix]]
91
- python = ["3.9", "3.10", "3.11"]
95
+ python = ["3.10", "3.11"]
92
96
 
93
97
  [tool.hatch.envs.lint]
94
98
  detached = true
@@ -113,14 +117,27 @@ all = [
113
117
  "typing",
114
118
  ]
115
119
 
120
+ [tool.hatch.envs.docs]
121
+ # NOT detached: autodoc imports the package, because docs/intro.rst pulls the
122
+ # module docstring in with automodule rather than keeping a second copy. A
123
+ # detached env would build with the package absent and the directive would fail.
124
+ dependencies = [
125
+ "sphinx>=7",
126
+ ]
127
+ [tool.hatch.envs.docs.scripts]
128
+ build = "sphinx-build -M html docs docs/_build"
129
+ clean = "sphinx-build -M clean docs docs/_build"
130
+
116
131
  [tool.black]
117
- target-version = ["py37"]
132
+ target-version = ["py310"]
118
133
  line-length = 120
119
134
  skip-string-normalization = true
120
135
 
121
136
  [tool.ruff]
122
- target-version = "py37"
137
+ target-version = "py310"
123
138
  line-length = 120
139
+
140
+ [tool.ruff.lint]
124
141
  select = [
125
142
  "A",
126
143
  "ARG",
@@ -163,13 +180,13 @@ unfixable = [
163
180
  "F401",
164
181
  ]
165
182
 
166
- [tool.ruff.isort]
183
+ [tool.ruff.lint.isort]
167
184
  known-first-party = ["git-project"]
168
185
 
169
- [tool.ruff.flake8-tidy-imports]
186
+ [tool.ruff.lint.flake8-tidy-imports]
170
187
  ban-relative-imports = "all"
171
188
 
172
- [tool.ruff.per-file-ignores]
189
+ [tool.ruff.lint.per-file-ignores]
173
190
  # Tests can use magic values, assertions, and relative imports
174
191
  "tests/**/*" = ["PLR2004", "S101", "TID252"]
175
192
 
@@ -213,6 +230,21 @@ text = """
213
230
 
214
231
  """
215
232
 
233
+ # The rest of the module docstring: everything past the badges. Without this
234
+ # the description stopped at the separator above, so Installation and the whole
235
+ # body were never published. The docstring is the single source; docs/intro.rst
236
+ # pulls the same text in with automodule.
237
+ [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
238
+ path = "src/git_project/__init__.py"
239
+ start-after = "-------\n"
240
+ end-before = "\"\"\""
241
+
242
+ # Fragments are concatenated verbatim and the docstring ends in an indented
243
+ # block, so without a blank line the next fragment unindents into it. That is an
244
+ # RST error and PyPI rejects a description that fails to render.
245
+ [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
246
+ text = "\n"
247
+
216
248
  [[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
217
249
  path = "docs/authors.rst"
218
250
  end-before = "A full list of contributors"
@@ -19,4 +19,4 @@
19
19
  # with git-project. If not, see <https://www.gnu.org/licenses/>.
20
20
 
21
21
  progressbar2>=4.4.2
22
- pygit2>=1.15.1
22
+ pygit2>=1.18.2
@@ -47,11 +47,7 @@ import subprocess
47
47
  import sys
48
48
  import urllib.parse
49
49
 
50
- try:
51
- from importlib import metadata
52
- except ImportError:
53
- # Running on pre-3.8 Python; use importlib-metadata package
54
- import importlib_metadata as metadata
50
+ from importlib import metadata
55
51
 
56
52
  from .parsermanager import ParserManager
57
53
 
@@ -32,23 +32,32 @@ import urllib
32
32
  from .exception import GitProjectException
33
33
  from .shell import capture_command
34
34
 
35
- # Python 3.9 things so we don't actually need 3.9.
36
-
37
- def _is_relative_to(path, other):
38
- try:
39
- path.relative_to(other)
40
- return True
41
- except ValueError:
42
- return False
43
-
44
35
  # Git commands
45
36
  #
46
37
  class Git(object):
47
38
  """A facade over a lower-level interface to git providing interfaces needed to
48
39
  implement git-project functionality."""
49
40
 
41
+ @staticmethod
42
+ def _ssh_credentials(ssh_id, username_from_url, allowed_types):
43
+ """Return the credentials to authenticate with, or None if we have none to
44
+ offer. Name the keypair under ~/.ssh for the given ssh ID. Offer no
45
+ keypair without an ssh ID.
46
+
47
+ """
48
+ if ssh_id and allowed_types & pygit2.enums.CredentialType.SSH_KEY:
49
+ return pygit2.Keypair(
50
+ username_from_url,
51
+ str(Path.home() / '.ssh' / f'{ssh_id}.pub'),
52
+ str(Path.home() / '.ssh' / ssh_id),
53
+ ''
54
+ )
55
+ elif allowed_types & pygit2.enums.CredentialType.USERNAME:
56
+ return pygit2.Username(username_from_url)
57
+ return None
58
+
50
59
  class RemoteCallbacks(pygit2.RemoteCallbacks):
51
- def __init__(self, ssh_id: str):
60
+ def __init__(self, ssh_id: str | None):
52
61
  self.started_transfer = False
53
62
  self.transfer_done = False
54
63
 
@@ -60,16 +69,8 @@ class Git(object):
60
69
  self.ssh_id = ssh_id
61
70
 
62
71
  def credentials(self, url, username_from_url, allowed_types):
63
- if allowed_types & pygit2.enums.CredentialType.SSH_KEY:
64
- return pygit2.Keypair(
65
- username_from_url,
66
- str(Path.home() / '.ssh' / f'{self.ssh_id}.pub'),
67
- str(Path.home() / '.ssh' / self.ssh_id),
68
- ''
69
- )
70
- elif allowed_types & pygit2.enums.CredentialType.USERNAME:
71
- return pygit2.Username(username_from_url)
72
- return None
72
+ return Git._ssh_credentials(self.ssh_id, username_from_url,
73
+ allowed_types)
73
74
 
74
75
  def sideband_progress(self, message):
75
76
  print(f'Remote: {message}')
@@ -377,21 +378,13 @@ class Git(object):
377
378
  del self._sections[section_name]
378
379
 
379
380
  class RemoteBranchDeleteCallback(pygit2.RemoteCallbacks):
380
- def __init__(self, ssh_id: str):
381
+ def __init__(self, ssh_id: str | None):
381
382
  self.ssh_id = ssh_id
382
383
 
383
384
  """Check the result of remove branch prune operations."""
384
385
  def credentials(self, url, username_from_url, allowed_types):
385
- if allowed_types & pygit2.enums.CredentialType.SSH_KEY:
386
- return pygit2.Keypair(
387
- username_from_url,
388
- str(Path.home() / '.ssh' / f'{self.ssh_id}.pub'),
389
- str(Path.home() / '.ssh' / self.ssh_id),
390
- ''
391
- )
392
- elif allowed_types & pygit2.enums.CredentialType.USERNAME:
393
- return pygit2.Username(username_from_url)
394
- return None
386
+ return Git._ssh_credentials(self.ssh_id, username_from_url,
387
+ allowed_types)
395
388
 
396
389
  def push_update_reference(self, refname, message):
397
390
  if message is not None:
@@ -399,40 +392,66 @@ class Git(object):
399
392
  format(message))
400
393
 
401
394
  class LsRemotesCallbacks(pygit2.RemoteCallbacks):
402
- def __init__(self, ssh_id: str):
395
+ def __init__(self, ssh_id: str | None):
403
396
  self.ssh_id = ssh_id
404
397
 
405
398
  def credentials(self, url, username_from_url, allowed_types):
406
- if allowed_types & pygit2.enums.CredentialType.SSH_KEY:
407
- return pygit2.Keypair(
408
- username_from_url,
409
- str(Path.home() / '.ssh' / f'{self.ssh_id}.pub'),
410
- str(Path.home() / '.ssh' / self.ssh_id),
411
- ''
412
- )
413
- elif allowed_types & pygit2.enums.CredentialType.USERNAME:
414
- return pygit2.Username(username_from_url)
415
- return None
399
+ return Git._ssh_credentials(self.ssh_id, username_from_url,
400
+ allowed_types)
416
401
 
417
402
  # Repository-wide info
418
403
  def __init__(self):
419
404
  self.reinit(Path.cwd())
420
405
 
421
- def reinit(self, gitdir: Path):
422
- repo_path = pygit2.discover_repository(gitdir)
406
+ def reinit(self, gitdir: Path | str):
407
+ """Attach to the repository that holds gitdir, or to no repository.
408
+
409
+ A caller may build a Git outside any repository. Record the path we
410
+ searched and leave the repository empty. has_repo then answers False,
411
+ and each repository accessor raises an error that names the path.
412
+
413
+ """
414
+ self._search_path = Path(gitdir).resolve()
415
+ repo_path = pygit2.discover_repository(self._search_path)
423
416
  if repo_path:
424
- self._repo = pygit2.Repository(repo_path)
425
- self._config = self.Config(self, self._repo.config)
417
+ self._repository = pygit2.Repository(repo_path)
418
+ self._config = self.Config(self, self._repository.config)
426
419
  self.validate_config()
420
+ else:
421
+ self._repository = None
422
+ self._config = None
423
+
424
+ def _no_repository_error(self):
425
+ """Return the error to raise when there is no repository."""
426
+ return GitProjectException(
427
+ f'No git repository at {self._search_path} or any parent')
428
+
429
+ @property
430
+ def _repo(self):
431
+ """Return the pygit2 repository. Raise if there is none.
432
+
433
+ Each accessor reads the repository through this property, so one guard
434
+ gives them all the same legible error.
435
+
436
+ """
437
+ if self._repository is None:
438
+ raise self._no_repository_error()
439
+ return self._repository
427
440
 
428
441
  @property
429
442
  def config(self):
430
- """Return the config of the repository."""
443
+ """Return the config of the repository.
444
+
445
+ Raise GitProjectException when there is no repository.
446
+
447
+ """
448
+ if self._repository is None or self._config is None:
449
+ raise self._no_repository_error()
431
450
  return self._config
432
451
 
433
452
  def has_repo(self):
434
453
  """Return whether we are attached to a repository."""
435
- return hasattr(self, '_repo')
454
+ return self._repository is not None
436
455
 
437
456
  def reload_config(self):
438
457
  """Reload the config."""
@@ -509,7 +528,7 @@ class Git(object):
509
528
  for name in self._repo.list_worktrees():
510
529
  worktree = self._repo.lookup_worktree(name)
511
530
  path = Path(worktree.path).resolve()
512
- if path == cwd or _is_relative_to(cwd, path):
531
+ if path == cwd or cwd.is_relative_to(path):
513
532
  return name
514
533
 
515
534
  return None
@@ -522,13 +541,13 @@ class Git(object):
522
541
  for name in self._repo.list_worktrees():
523
542
  worktree = self._repo.lookup_worktree(name)
524
543
  path = Path(worktree.path).resolve()
525
- if path == cwd or _is_relative_to(cwd, path):
544
+ if path == cwd or cwd.is_relative_to(path):
526
545
  return str(path)
527
546
 
528
547
  # See if we have a GITDIR somewhere along the way.
529
548
  gitdir = Path(self._repo.path).resolve()
530
549
  while True:
531
- if gitdir != cwd and _is_relative_to(gitdir, cwd):
550
+ if gitdir != cwd and gitdir.is_relative_to(cwd):
532
551
  return cwd
533
552
  parent = cwd.parent
534
553
  if parent == cwd:
@@ -567,8 +586,13 @@ class Git(object):
567
586
 
568
587
  def committish_exists(self, committish):
569
588
  """Return whether the committish exists in the repository."""
589
+ # Read the repository before the try. The bare except below would
590
+ # otherwise swallow the no-repository error and answer False, which
591
+ # says the committish is absent rather than that there is nothing to
592
+ # look in.
593
+ repo = self._repo
570
594
  try:
571
- self._repo.revparse_single(committish)
595
+ repo.revparse_single(committish)
572
596
  return True
573
597
  except:
574
598
  return False
@@ -770,16 +794,22 @@ class Git(object):
770
794
  result = branch.upstream.branch_name
771
795
  return result
772
796
 
773
- def clone(self, url, ssh_id, path=None, bare=False):
774
- """Clone a respository at the given url, making a bare clone if specified."""
797
+ def clone(self, url, ssh_id=None, path=None, bare=False):
798
+ """Clone a repository at the given url, making a bare clone if specified.
799
+ Authenticate with the ssh ID if one is given. A clone runs before there
800
+ is a repository config to read one from, so the caller supplies it.
801
+ Without an ssh ID we offer no key, which suits a local or an anonymous
802
+ url. An ssh url instead fails to authenticate.
803
+
804
+ """
775
805
  parsed_url = urllib.parse.urlparse(url)
776
806
  url_path = Path(parsed_url.path).resolve()
777
807
  url_name = url_path.name
778
808
  target_path = path if path else str(Path.cwd() / url_name)
779
809
 
780
810
  callbacks = Git.RemoteCallbacks(ssh_id)
781
- self._repo = pygit2.clone_repository(url, target_path, bare, callbacks=callbacks)
782
- self._config = self.Config(self, self._repo.config)
811
+ self._repository = pygit2.clone_repository(url, target_path, bare, callbacks=callbacks)
812
+ self._config = self.Config(self, self._repository.config)
783
813
 
784
814
  return str(Path(target_path).resolve())
785
815
 
@@ -835,18 +865,18 @@ class Git(object):
835
865
  self._repo.branches.delete(branch_name)
836
866
 
837
867
  def remote_branch_exists(self, branch_name, remote):
838
- """Return whethe the given branch exists on the given remote."""
868
+ """Return whether the given branch exists on the given remote."""
839
869
  refname = self.branch_name_to_refname(branch_name)
840
870
  # FIXME: This is likely to be something like
841
871
  # refs/remotes/<remote>/<branch_name> but that is not what it's called
842
- # in the ls_remotes call. There doesn't seem to be a way to get the
872
+ # in the list_heads call. There doesn't seem to be a way to get the
843
873
  # (local) name of the branch on the remote side. Assume for now that
844
874
  # it's the same as our local name.
845
875
  remote_refname = self.get_remote_push_refname(refname, remote)
846
- for item in self._repo.remotes[remote].ls_remotes(
876
+ for item in self._repo.remotes[remote].list_heads(
847
877
  callbacks=Git.LsRemotesCallbacks(self.get_ssh_id())
848
878
  ):
849
- if not item['local'] and item['name'] == refname:
879
+ if not item.local and item.name == refname:
850
880
  return True
851
881
  return False
852
882