repligit 0.2.0__tar.gz → 0.3.0__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 (38) hide show
  1. {repligit-0.2.0 → repligit-0.3.0}/.envrc +1 -1
  2. {repligit-0.2.0 → repligit-0.3.0}/.github/dependabot.yml +6 -2
  3. {repligit-0.2.0 → repligit-0.3.0}/.github/workflows/ci.yml +2 -2
  4. {repligit-0.2.0 → repligit-0.3.0}/.github/workflows/coverage.yml +5 -5
  5. {repligit-0.2.0 → repligit-0.3.0}/.github/workflows/label.yml +1 -1
  6. {repligit-0.2.0 → repligit-0.3.0}/.github/workflows/publish.yml +5 -5
  7. repligit-0.3.0/.github/workflows/requirements/publish.txt +2 -0
  8. repligit-0.3.0/.github/workflows/requirements/security.txt +1 -0
  9. repligit-0.3.0/.github/workflows/requirements/style.txt +3 -0
  10. repligit-0.3.0/.github/workflows/requirements/unit-tests.txt +5 -0
  11. {repligit-0.2.0 → repligit-0.3.0}/.github/workflows/security.yml +6 -6
  12. {repligit-0.2.0 → repligit-0.3.0}/.github/workflows/style.yml +6 -2
  13. {repligit-0.2.0 → repligit-0.3.0}/.github/workflows/unit-tests.yml +3 -3
  14. {repligit-0.2.0 → repligit-0.3.0}/PKG-INFO +8 -1
  15. {repligit-0.2.0 → repligit-0.3.0}/examples/git_mirror.py +4 -0
  16. {repligit-0.2.0 → repligit-0.3.0}/examples/git_mirror_async.py +4 -0
  17. {repligit-0.2.0 → repligit-0.3.0}/pyproject.toml +6 -1
  18. {repligit-0.2.0/spack → repligit-0.3.0}/spack.yaml +3 -3
  19. {repligit-0.2.0 → repligit-0.3.0}/src/repligit/asyncio/client.py +45 -16
  20. {repligit-0.2.0 → repligit-0.3.0}/src/repligit/asyncio/parse.py +1 -1
  21. {repligit-0.2.0 → repligit-0.3.0}/src/repligit/client.py +54 -17
  22. repligit-0.3.0/src/repligit/exceptions.py +18 -0
  23. {repligit-0.2.0 → repligit-0.3.0}/src/repligit/parse.py +4 -4
  24. {repligit-0.2.0 → repligit-0.3.0}/tests/test_client.py +6 -6
  25. repligit-0.2.0/.github/workflows/requirements/publish.txt +0 -2
  26. repligit-0.2.0/.github/workflows/requirements/security.txt +0 -1
  27. repligit-0.2.0/.github/workflows/requirements/style.txt +0 -2
  28. repligit-0.2.0/.github/workflows/requirements/unit-tests.txt +0 -5
  29. {repligit-0.2.0 → repligit-0.3.0}/.github/ISSUE_TEMPLATE/issue-feature-request.md +0 -0
  30. {repligit-0.2.0 → repligit-0.3.0}/.github/labeler.yml +0 -0
  31. {repligit-0.2.0 → repligit-0.3.0}/.gitignore +0 -0
  32. {repligit-0.2.0 → repligit-0.3.0}/LICENSE +0 -0
  33. {repligit-0.2.0 → repligit-0.3.0}/README.md +0 -0
  34. {repligit-0.2.0 → repligit-0.3.0}/spack/repos/spack_repo/repligit/packages/py_repligit/package.py +0 -0
  35. {repligit-0.2.0 → repligit-0.3.0}/spack/repos/spack_repo/repligit/repo.yaml +0 -0
  36. {repligit-0.2.0 → repligit-0.3.0}/src/repligit/__init__.py +0 -0
  37. {repligit-0.2.0 → repligit-0.3.0}/src/repligit/asyncio/__init__.py +0 -0
  38. {repligit-0.2.0 → repligit-0.3.0}/tests/__init__.py +0 -0
@@ -6,5 +6,5 @@
6
6
  #------------------------------------------------------------------------
7
7
  if type spack &>/dev/null; then
8
8
  . $SPACK_ROOT/share/spack/setup-env.sh
9
- spack env activate -d spack/
9
+ spack env activate -d .
10
10
  fi
@@ -3,9 +3,13 @@ updates:
3
3
  - package-ecosystem: "github-actions"
4
4
  directory: "/"
5
5
  schedule:
6
- interval: "weekly"
6
+ interval: "monthly"
7
+ groups:
8
+ codeql:
9
+ patterns:
10
+ - "github/codeql-action*"
7
11
 
8
12
  - package-ecosystem: "pip"
9
13
  directory: "/.github/workflows/requirements"
10
14
  schedule:
11
- interval: "weekly"
15
+ interval: "monthly"
@@ -22,13 +22,13 @@ jobs:
22
22
  security: ${{ steps.filter.outputs.security }}
23
23
  unit-tests: ${{ steps.filter.outputs.unit-tests }}
24
24
  steps:
25
- - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # @v2
25
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # @v2
26
26
  if: ${{ github.event_name == 'push' }}
27
27
  with:
28
28
  fetch-depth: 0
29
29
 
30
30
  # For pull requests it's not necessary to checkout the code
31
- - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36
31
+ - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d
32
32
  id: filter
33
33
  with:
34
34
  filters: |
@@ -13,10 +13,10 @@ jobs:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
15
  - name: checkout PR branch
16
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3
16
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
17
17
  with:
18
18
  fetch-depth: 2 # https://stackoverflow.com/a/74268200
19
- - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
19
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
20
20
  with:
21
21
  python-version: '3.13'
22
22
  cache: 'pip'
@@ -33,7 +33,7 @@ jobs:
33
33
  if: github.event_name == 'pull_request'
34
34
  id: download_pr_cov
35
35
  continue-on-error: true
36
- uses: actions/download-artifact@v6
36
+ uses: actions/download-artifact@v8.0.1
37
37
  with:
38
38
  name: pr-coverage-${{ github.sha }}
39
39
 
@@ -64,7 +64,7 @@ jobs:
64
64
  if: github.event_name == 'pull_request'
65
65
  id: download_base
66
66
  continue-on-error: true
67
- uses: actions/download-artifact@v6
67
+ uses: actions/download-artifact@v8.0.1
68
68
  with:
69
69
  name: base-coverage-${{ env.MERGE_BASE_COMMIT }}
70
70
 
@@ -110,7 +110,7 @@ jobs:
110
110
 
111
111
  - name: Upload base coverage artifact
112
112
  if: github.ref_name == 'main' && github.event_name == 'push'
113
- uses: actions/upload-artifact@v5
113
+ uses: actions/upload-artifact@v7
114
114
  with:
115
115
  name: base-coverage-${{ github.sha }}
116
116
  path: base_coverage.json
@@ -18,4 +18,4 @@ jobs:
18
18
  contents: read
19
19
  pull-requests: write
20
20
  steps:
21
- - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b
21
+ - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213
@@ -7,12 +7,12 @@ jobs:
7
7
  build:
8
8
  runs-on: ubuntu-latest
9
9
  steps:
10
- - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3
10
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
11
11
  with:
12
12
  persist-credentials: false
13
13
 
14
14
  - name: Set up Python
15
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
15
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
16
16
  with:
17
17
  cache: 'pip'
18
18
  cache-dependency-path: '.github/workflows/requirements/publish.txt'
@@ -26,7 +26,7 @@ jobs:
26
26
  python3 -m build
27
27
 
28
28
  - name: Save distribution files as artifact
29
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
29
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
30
30
  with:
31
31
  name: python-package-distributions
32
32
  path: dist/
@@ -41,10 +41,10 @@ jobs:
41
41
  id-token: write
42
42
  steps:
43
43
  - name: Download distribution files from artifact
44
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
44
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
45
45
  with:
46
46
  name: python-package-distributions
47
47
  path: dist/
48
48
 
49
49
  - name: Publish package to PyPI
50
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e
50
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
@@ -0,0 +1,2 @@
1
+ hatchling==1.29.0
2
+ build==1.4.2
@@ -0,0 +1 @@
1
+ bandit[sarif,toml]==1.9.4
@@ -0,0 +1,3 @@
1
+ ruff==0.15.8
2
+ ty==0.0.29
3
+ typos==1.45.0
@@ -0,0 +1,5 @@
1
+ hatchling==1.29.0
2
+ build==1.4.2
3
+ pytest==9.0.2
4
+ pytest-mock==3.15.1
5
+ coverage==7.13.5
@@ -10,11 +10,11 @@ jobs:
10
10
  bandit-scan:
11
11
  runs-on: ubuntu-latest
12
12
  steps:
13
- - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3
13
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
14
14
 
15
15
  # https://bandit.readthedocs.io/en/latest/faq.html#under-which-version-of-python-should-i-install-bandit
16
16
  - name: Set up Python 3.10
17
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
17
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
18
18
  with:
19
19
  python-version: '3.10'
20
20
  cache: 'pip'
@@ -30,7 +30,7 @@ jobs:
30
30
  # the PR if any issues are found
31
31
  - name: Upload SARIF file
32
32
  if: always()
33
- uses: github/codeql-action/upload-sarif@fe4161a26a8629af62121b670040955b330f9af2
33
+ uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a
34
34
  with:
35
35
  sarif_file: results.sarif
36
36
  category: bandit
@@ -43,15 +43,15 @@ jobs:
43
43
  language: [actions, python]
44
44
  steps:
45
45
  - name: Checkout repository
46
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3
46
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
47
47
 
48
48
  - name: Initialize CodeQL
49
- uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2
49
+ uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a
50
50
  with:
51
51
  languages: ${{ matrix.language }}
52
52
  build-mode: none
53
53
 
54
54
  - name: Perform CodeQL Analysis
55
- uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2
55
+ uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a
56
56
  with:
57
57
  category: "/language:${{matrix.language}}"
@@ -9,10 +9,10 @@ jobs:
9
9
  lint:
10
10
  runs-on: ubuntu-latest
11
11
  steps:
12
- - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3
12
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
13
13
 
14
14
  - name: Set up Python 3.10
15
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
15
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
16
16
  with:
17
17
  python-version: '3.10'
18
18
  cache: 'pip'
@@ -21,6 +21,7 @@ jobs:
21
21
  - name: Install Python dependencies
22
22
  run: |
23
23
  pip install -r .github/workflows/requirements/style.txt
24
+ pip install -e .[aiohttp]
24
25
 
25
26
  - name: Run Ruff
26
27
  run: |
@@ -28,5 +29,8 @@ jobs:
28
29
  ruff check --select I --diff
29
30
  ruff format --check --diff
30
31
 
32
+ - name: Run Type Checker
33
+ run: ty check
34
+
31
35
  - name: Run Typos
32
36
  run: typos
@@ -12,8 +12,8 @@ jobs:
12
12
  matrix:
13
13
  python-version: ['3.10', '3.13']
14
14
  steps:
15
- - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3
16
- - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
15
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
16
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
17
17
  with:
18
18
  python-version: ${{ matrix.python-version }}
19
19
  cache: 'pip'
@@ -41,7 +41,7 @@ jobs:
41
41
  - name: Upload coverage artifact
42
42
  # this is needed so we can compare diffs in coverage.yml
43
43
  if: ${{ matrix.python-version == '3.13' }}
44
- uses: actions/upload-artifact@v5
44
+ uses: actions/upload-artifact@v7
45
45
  with:
46
46
  name: pr-coverage-${{ github.sha }}
47
47
  path: pr_coverage.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repligit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A python library implementing the git http transfer protocol.
5
5
  Project-URL: Homepage, https://github.com/llnl/repligit
6
6
  Project-URL: Issues, https://github.com/llnl/repligit/issues
@@ -12,6 +12,13 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.10
13
13
  Provides-Extra: aiohttp
14
14
  Requires-Dist: aiohttp; extra == 'aiohttp'
15
+ Provides-Extra: dev
16
+ Requires-Dist: coverage; extra == 'dev'
17
+ Requires-Dist: pytest; extra == 'dev'
18
+ Requires-Dist: pytest-mock; extra == 'dev'
19
+ Requires-Dist: ruff; extra == 'dev'
20
+ Requires-Dist: ty; extra == 'dev'
21
+ Requires-Dist: typos; extra == 'dev'
15
22
  Description-Content-Type: text/markdown
16
23
 
17
24
  # repligit
@@ -40,6 +40,10 @@ def main():
40
40
  # password=src_password, # Uncomment if source repo requires auth
41
41
  )
42
42
 
43
+ if packfile is None:
44
+ print("Failed to fetch packfile")
45
+ return
46
+
43
47
  # Upload packfile to destination repository
44
48
  send_pack(
45
49
  dest_remote_url,
@@ -44,6 +44,10 @@ async def main():
44
44
  # password=src_password, # Uncomment if source repo requires auth
45
45
  )
46
46
 
47
+ if packfile is None:
48
+ print("Failed to fetch packfile")
49
+ return
50
+
47
51
  # Upload packfile to destination repository
48
52
  await send_pack(
49
53
  dest_remote_url,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "repligit"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  dependencies = []
9
9
  authors = [
10
10
  { name="Alec Scott" },
@@ -25,6 +25,7 @@ Issues = "https://github.com/llnl/repligit/issues"
25
25
 
26
26
  [project.optional-dependencies]
27
27
  aiohttp = ["aiohttp"]
28
+ dev = ["ty", "ruff", "typos", "coverage", "pytest", "pytest-mock"]
28
29
 
29
30
  [tool.pytest.ini_options]
30
31
  pythonpath = [
@@ -39,3 +40,7 @@ exclude = ["spack"]
39
40
  exclude_dirs = []
40
41
  # allow use of assert
41
42
  skips = ["B101"]
43
+
44
+ [tool.ty.src]
45
+ # exclude spack directory from type checking
46
+ exclude = ["spack/**"]
@@ -3,9 +3,8 @@
3
3
  # It describes a set of packages to be installed, along with
4
4
  # configuration settings.
5
5
  spack:
6
- # add package specs to the `specs` list
7
6
  repos:
8
- - $env/repos/spack_repo/repligit
7
+ repligit: spack/repos/spack_repo/repligit
9
8
  specs:
10
9
  - py-codespell
11
10
  - py-pytest
@@ -13,10 +12,11 @@ spack:
13
12
  - py-bandit
14
13
  - py-ruff
15
14
  - py-pip
15
+ - py-ty
16
16
  view: true
17
17
  concretizer:
18
18
  unify: true
19
19
  develop:
20
20
  py-repligit:
21
21
  spec: py-repligit@=main
22
- path: $env/../
22
+ path: .
@@ -1,23 +1,32 @@
1
- from typing import Set
1
+ from typing import Iterable
2
2
 
3
3
  import aiohttp
4
4
 
5
5
  from repligit.asyncio.parse import decode_lines, iter_lines
6
+ from repligit.exceptions import (
7
+ RefUpdateRejected,
8
+ RemoteError,
9
+ UnexpectedResponse,
10
+ UnpackFailed,
11
+ )
6
12
  from repligit.parse import generate_fetch_pack_request, generate_send_pack_header
7
13
 
8
14
 
9
- async def ls_remote(url: str, username: str = None, password: str = None):
15
+ async def ls_remote(
16
+ url: str, username: str | None = None, password: str | None = None
17
+ ) -> dict[str, str]:
10
18
  """Get commit hash of remote master branch, return SHA-1 hex string or
11
19
  None if no remote commits.
12
20
  """
13
21
 
14
22
  url = f"{url}/info/refs?service=git-upload-pack"
15
- auth = aiohttp.BasicAuth(username, password) if username or password else None
23
+ auth = aiohttp.BasicAuth(username or "", password) if password else None
16
24
  async with aiohttp.ClientSession(auth=auth) as session:
17
25
  async with session.get(url, raise_for_status=True) as resp:
18
26
  lines = decode_lines(iter_lines(resp, encoding="utf-8"))
19
27
  service_line = await anext(lines)
20
- assert service_line == "# service=git-upload-pack"
28
+ if service_line != "# service=git-upload-pack":
29
+ raise UnexpectedResponse(f"invalid service line: {service_line!r}")
21
30
 
22
31
  # `async for` inside `dict()` not supported so no dict comprehension
23
32
  result = {}
@@ -30,15 +39,18 @@ async def ls_remote(url: str, username: str = None, password: str = None):
30
39
 
31
40
 
32
41
  async def fetch_pack(
33
- url: str, want_sha: str, have_shas: Set[str], username=None, password=None
34
- ):
42
+ url: str,
43
+ want_sha: str,
44
+ have_shas: Iterable[str],
45
+ username: str | None = None,
46
+ password: str | None = None,
47
+ ) -> bytes | None:
35
48
  """Download a packfile from a remote server."""
36
49
  # ensure have_shas is a set, else packfile errors will occur
37
- if not isinstance(have_shas, set):
38
- have_shas = set(have_shas)
50
+ have_shas = set(have_shas)
39
51
 
40
52
  url = f"{url}/git-upload-pack"
41
- auth = aiohttp.BasicAuth(username, password) if username or password else None
53
+ auth = aiohttp.BasicAuth(username or "", password) if password else None
42
54
 
43
55
  request = generate_fetch_pack_request(want_sha, have_shas)
44
56
 
@@ -56,6 +68,11 @@ async def fetch_pack(
56
68
  line_length = int(length_bytes, 16)
57
69
 
58
70
  line = await resp.content.readexactly(line_length - 4)
71
+
72
+ # e.g. "ERR upload-pack: not our ref <sha>"
73
+ if line[:3] == b"ERR":
74
+ raise RemoteError(line.decode("utf-8").strip())
75
+
59
76
  if line[:3] == b"NAK" or line[:3] == b"ACK":
60
77
  # this is a difference in API between sync and async
61
78
  # has to be read within this context to be used in the caller
@@ -69,13 +86,13 @@ async def send_pack(
69
86
  ref: str,
70
87
  from_sha: str,
71
88
  to_sha: str,
72
- packfile,
73
- username: str = None,
74
- password: str = None,
75
- ):
89
+ packfile: bytes,
90
+ username: str | None = None,
91
+ password: str | None = None,
92
+ ) -> None:
76
93
  """Send a packfile to a remote server."""
77
94
  url = f"{url}/git-receive-pack"
78
- auth = aiohttp.BasicAuth(username, password) if username or password else None
95
+ auth = aiohttp.BasicAuth(username or "", password) if password else None
79
96
 
80
97
  header = generate_send_pack_header(ref, from_sha, to_sha)
81
98
  # unlike in the sync version the packfile is already read into memory
@@ -91,5 +108,17 @@ async def send_pack(
91
108
  raise_for_status=True,
92
109
  ) as resp:
93
110
  lines = decode_lines(iter_lines(resp, encoding="utf-8"))
94
- assert await anext(lines) == "unpack ok"
95
- assert await anext(lines) == f"ok {ref}"
111
+ unpack_status = await anext(lines)
112
+ if unpack_status != "unpack ok":
113
+ raise UnpackFailed(unpack_status)
114
+
115
+ # "ng <ref> <reason>" (ng = not good) means the remote rejected the
116
+ # update. The reason may be non-fast-forward, hook declined, etc.
117
+ ref_status = await anext(lines)
118
+ prefix = f"ng {ref} "
119
+ if ref_status.startswith(prefix):
120
+ reason_str = ref_status[len(prefix) :]
121
+ raise RefUpdateRejected(reason_str)
122
+
123
+ if ref_status != f"ok {ref}":
124
+ raise UnexpectedResponse(f"unexpected ref status line: {ref_status!r}")
@@ -23,7 +23,7 @@ async def decode_lines(line_stream: AsyncIterable) -> AsyncIterator:
23
23
 
24
24
  async def iter_lines(
25
25
  resp: aiohttp.ClientResponse, encoding: str = "utf-8", chunk_size: int = 16 * 1024
26
- ):
26
+ ) -> AsyncIterator[str]:
27
27
  """
28
28
  Asynchronously iterate over the lines of an HTTP response.
29
29
 
@@ -1,6 +1,13 @@
1
1
  import urllib.request
2
- from typing import Set
3
-
2
+ from http.client import HTTPResponse
3
+ from typing import BinaryIO, Iterable
4
+
5
+ from repligit.exceptions import (
6
+ RefUpdateRejected,
7
+ RemoteError,
8
+ UnexpectedResponse,
9
+ UnpackFailed,
10
+ )
4
11
  from repligit.parse import (
5
12
  decode_lines,
6
13
  generate_fetch_pack_request,
@@ -9,7 +16,13 @@ from repligit.parse import (
9
16
  )
10
17
 
11
18
 
12
- def http_request(url, headers=None, username=None, password=None, data=None):
19
+ def http_request(
20
+ url: str,
21
+ headers: dict[str, str] | None = None,
22
+ username: str | None = None,
23
+ password: str | None = None,
24
+ data: bytes | None = None,
25
+ ) -> HTTPResponse:
13
26
  """
14
27
  Constructs and executes an HTTP request using urllib. (GET by default,
15
28
  POST if "data" is not None).
@@ -25,7 +38,9 @@ def http_request(url, headers=None, username=None, password=None, data=None):
25
38
  file-like object: The response file handler from the request
26
39
  """
27
40
  password_manager = urllib.request.HTTPPasswordMgrWithDefaultRealm()
28
- password_manager.add_password(None, url, username, password)
41
+
42
+ if password:
43
+ password_manager.add_password(None, url, username or "", password)
29
44
 
30
45
  auth_handler = urllib.request.HTTPBasicAuthHandler(password_manager)
31
46
  opener = urllib.request.build_opener(auth_handler)
@@ -39,7 +54,9 @@ def http_request(url, headers=None, username=None, password=None, data=None):
39
54
  return opener.open(request)
40
55
 
41
56
 
42
- def ls_remote(url: str, username: str = None, password: str = None):
57
+ def ls_remote(
58
+ url: str, username: str | None = None, password: str | None = None
59
+ ) -> dict[str, str]:
43
60
  """Get commit hash of remote master branch, return SHA-1 hex string or
44
61
  None if no remote commits.
45
62
  """
@@ -49,18 +66,22 @@ def ls_remote(url: str, username: str = None, password: str = None):
49
66
 
50
67
  lines = decode_lines(iter_lines(resp))
51
68
  service_line = next(lines)
52
- assert service_line == "# service=git-upload-pack"
69
+ if service_line != "# service=git-upload-pack":
70
+ raise UnexpectedResponse(f"invalid service line: {service_line!r}")
53
71
 
54
- return dict(reversed(line.split()) for line in lines if line)
72
+ return {ref: sha for sha, ref in (line.split() for line in lines if line)}
55
73
 
56
74
 
57
75
  def fetch_pack(
58
- url: str, want_sha: str, have_shas: Set[str], username=None, password=None
59
- ):
76
+ url: str,
77
+ want_sha: str,
78
+ have_shas: Iterable[str],
79
+ username: str | None = None,
80
+ password: str | None = None,
81
+ ) -> HTTPResponse | None:
60
82
  """Download a packfile from a remote server."""
61
83
  # ensure have_shas is a set, else packfile errors will occur
62
- if not isinstance(have_shas, set):
63
- have_shas = set(have_shas)
84
+ have_shas = set(have_shas)
64
85
 
65
86
  url = f"{url}/git-upload-pack"
66
87
  request = generate_fetch_pack_request(want_sha, have_shas)
@@ -78,6 +99,10 @@ def fetch_pack(
78
99
  line_length = int(resp.read(4), 16)
79
100
  line = resp.read(line_length - 4)
80
101
 
102
+ # e.g. "ERR upload-pack: not our ref <sha>"
103
+ if line[:3] == b"ERR":
104
+ raise RemoteError(line.decode("utf-8").strip())
105
+
81
106
  if line[:3] == b"NAK" or line[:3] == b"ACK":
82
107
  return resp
83
108
  else:
@@ -89,10 +114,10 @@ def send_pack(
89
114
  ref: str,
90
115
  from_sha: str,
91
116
  to_sha: str,
92
- packfile,
93
- username: str = None,
94
- password: str = None,
95
- ):
117
+ packfile: BinaryIO,
118
+ username: str | None = None,
119
+ password: str | None = None,
120
+ ) -> None:
96
121
  """Send a packfile to a remote server."""
97
122
  url = f"{url}/git-receive-pack"
98
123
 
@@ -110,5 +135,17 @@ def send_pack(
110
135
  )
111
136
 
112
137
  lines = decode_lines(iter_lines(resp))
113
- assert next(lines) == "unpack ok"
114
- assert next(lines) == f"ok {ref}"
138
+ unpack_status = next(lines)
139
+ if unpack_status != "unpack ok":
140
+ raise UnpackFailed(unpack_status)
141
+
142
+ # "ng <ref> <reason>" (ng = not good) means the remote rejected the update.
143
+ # The reason can be non-fast-forward, hook declined, missing objects, etc.
144
+ ref_status = next(lines)
145
+ prefix = f"ng {ref} "
146
+ if ref_status.startswith(prefix):
147
+ reason_str = ref_status[len(prefix) :]
148
+ raise RefUpdateRejected(reason_str)
149
+
150
+ if ref_status != f"ok {ref}":
151
+ raise UnexpectedResponse(f"unexpected ref status line: {ref_status!r}")
@@ -0,0 +1,18 @@
1
+ class RepligitError(Exception):
2
+ """Base class for all repligit errors."""
3
+
4
+
5
+ class RefUpdateRejected(RepligitError):
6
+ """Raised when the remote rejects a ref update during send-pack."""
7
+
8
+
9
+ class RemoteError(RepligitError):
10
+ """Raised when the remote sends an ERR packet (e.g. want SHA not found)."""
11
+
12
+
13
+ class UnpackFailed(RepligitError):
14
+ """Raised when the remote fails to unpack the packfile during send-pack."""
15
+
16
+
17
+ class UnexpectedResponse(RepligitError):
18
+ """Raised when the remote sends a response repligit does not recognize."""
@@ -1,9 +1,9 @@
1
- from typing import IO, Generator, List, Set, Union
1
+ from typing import IO, Generator, Iterable, Set
2
2
 
3
3
 
4
4
  def iter_lines(
5
5
  data: IO, encoding: str = "utf-8", chunk_size: int = 16 * 1024
6
- ) -> Generator[str, str, str]:
6
+ ) -> Generator[str, None, None]:
7
7
  """
8
8
  Iterate over the lines of a file-like object, yielding one line at a time.
9
9
 
@@ -30,7 +30,7 @@ def iter_lines(
30
30
  yield incomplete_line.rstrip(b"\r").decode(encoding)
31
31
 
32
32
 
33
- def decode_lines(lines: Generator[str, str, str]) -> Generator[str, str, str]:
33
+ def decode_lines(lines: Iterable[str]) -> Generator[str, None, None]:
34
34
  """
35
35
  Decode lines from the git transfer protocol into usable lines.
36
36
 
@@ -49,7 +49,7 @@ def decode_lines(lines: Generator[str, str, str]) -> Generator[str, str, str]:
49
49
  yield line[4:line_length]
50
50
 
51
51
 
52
- def encode_lines(lines: List[Union[bytes, str]]) -> bytes:
52
+ def encode_lines(lines: Iterable[bytes | str]) -> bytes:
53
53
  """
54
54
  Encode a list of lines into a byte string format for git transmission.
55
55
 
@@ -3,15 +3,15 @@ from repligit.parse import decode_lines, encode_lines, generate_send_pack_header
3
3
 
4
4
  def test_decode_lines():
5
5
  raw_lines = [
6
- b"003fbef547a59eec448284136f03984dce0f2f8239a9 refs/pull/95/head",
7
- b"003f358aa046cd57dbca306e80d4c3fbb86edc5b36af refs/pull/96/head",
8
- b"0000",
6
+ "003fbef547a59eec448284136f03984dce0f2f8239a9 refs/pull/95/head",
7
+ "003f358aa046cd57dbca306e80d4c3fbb86edc5b36af refs/pull/96/head",
8
+ "0000",
9
9
  ]
10
10
 
11
11
  decoded_lines = [
12
- b"bef547a59eec448284136f03984dce0f2f8239a9 refs/pull/95/head",
13
- b"358aa046cd57dbca306e80d4c3fbb86edc5b36af refs/pull/96/head",
14
- b"",
12
+ "bef547a59eec448284136f03984dce0f2f8239a9 refs/pull/95/head",
13
+ "358aa046cd57dbca306e80d4c3fbb86edc5b36af refs/pull/96/head",
14
+ "",
15
15
  ]
16
16
 
17
17
  lines = list(decode_lines(raw_lines))
@@ -1,2 +0,0 @@
1
- hatchling==1.28.0
2
- build==1.3.0
@@ -1 +0,0 @@
1
- bandit[sarif,toml]==1.9.2
@@ -1,2 +0,0 @@
1
- ruff==0.14.7
2
- typos==1.40.0
@@ -1,5 +0,0 @@
1
- hatchling==1.28.0
2
- build==1.3.0
3
- pytest==9.0.1
4
- pytest-mock==3.15.1
5
- coverage==7.12.0
File without changes
File without changes
File without changes
File without changes
File without changes