Skip to content

Commit cee8cff

Browse files
committed
refactor: switch to git ls-remote for repo existence check, update tests
1 parent 42f6946 commit cee8cff

File tree

2 files changed

+58
-53
lines changed

2 files changed

+58
-53
lines changed

src/gitingest/utils/git_utils.py

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
from urllib.parse import urlparse, urlunparse
1212

1313
import git
14-
import httpx
15-
from starlette.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
1614

1715
from gitingest.utils.compat_func import removesuffix
1816
from gitingest.utils.exceptions import InvalidGitHubTokenError
@@ -122,7 +120,7 @@ async def ensure_git_installed() -> None:
122120

123121

124122
async def check_repo_exists(url: str, token: str | None = None) -> bool:
125-
"""Check whether a remote Git repository is reachable.
123+
"""Check whether a remote Git repository is reachable using git ls-remote.
126124
127125
Parameters
128126
----------
@@ -136,35 +134,28 @@ async def check_repo_exists(url: str, token: str | None = None) -> bool:
136134
bool
137135
``True`` if the repository exists, ``False`` otherwise.
138136
139-
Raises
140-
------
141-
RuntimeError
142-
If the host returns an unrecognised status code.
143-
144137
"""
145-
headers = {}
138+
try:
139+
await ensure_git_installed()
146140

147-
if token and is_github_host(url):
148-
host, owner, repo = _parse_github_url(url)
149-
# Public GitHub vs. GitHub Enterprise
150-
base_api = "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
151-
url = f"{base_api}/repos/{owner}/{repo}"
152-
headers["Authorization"] = f"Bearer {token}"
141+
git_cmd = git.Git()
153142

154-
async with httpx.AsyncClient(follow_redirects=True) as client:
155-
try:
156-
response = await client.head(url, headers=headers)
157-
except httpx.RequestError:
158-
return False
143+
# Add token to URL if provided and it's a GitHub repository
144+
auth_url = url
145+
if token and is_github_host(url):
146+
auth_url = _add_token_to_url(url, token)
159147

160-
status_code = response.status_code
148+
# Use git ls-remote to check if repository exists
149+
# This will return refs if repo exists, or fail if it doesn't
150+
git_cmd.ls_remote(auth_url, "--exit-code")
161151

162-
if status_code == HTTP_200_OK:
163-
return True
164-
if status_code in {HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND}:
152+
except git.GitCommandError:
153+
# Repository doesn't exist, is private without proper auth, or other git error
154+
return False
155+
except Exception:
156+
# Git not installed or other system error
165157
return False
166-
msg = f"Unexpected HTTP status {status_code} for {url}"
167-
raise RuntimeError(msg)
158+
return True
168159

169160

170161
def _parse_github_url(url: str) -> tuple[str, str, str]:

tests/test_clone.py

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@
88

99
import sys
1010
from typing import TYPE_CHECKING
11-
from unittest.mock import AsyncMock
1211

13-
import httpx
12+
import git
1413
import pytest
15-
from starlette.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
1614

1715
from gitingest.clone import clone_repo
1816
from gitingest.schemas import CloneConfig
@@ -21,6 +19,7 @@
2119

2220
if TYPE_CHECKING:
2321
from pathlib import Path
22+
from unittest.mock import AsyncMock
2423

2524
from pytest_mock import MockerFixture
2625

@@ -93,24 +92,31 @@ async def test_clone_nonexistent_repository(repo_exists_true: AsyncMock) -> None
9392

9493
@pytest.mark.asyncio
9594
@pytest.mark.parametrize(
96-
("status_code", "expected"),
95+
("git_command_succeeds", "expected"),
9796
[
98-
(HTTP_200_OK, True),
99-
(HTTP_401_UNAUTHORIZED, False),
100-
(HTTP_403_FORBIDDEN, False),
101-
(HTTP_404_NOT_FOUND, False),
97+
(True, True), # git ls-remote succeeds -> repo exists
98+
(False, False), # git ls-remote fails -> repo doesn't exist or no access
10299
],
103100
)
104-
async def test_check_repo_exists(status_code: int, *, expected: bool, mocker: MockerFixture) -> None:
105-
"""Verify that ``check_repo_exists`` interprets httpx results correctly."""
106-
mock_client = AsyncMock()
107-
mock_client.__aenter__.return_value = mock_client # context-manager protocol
108-
mock_client.head.return_value = httpx.Response(status_code=status_code)
109-
mocker.patch("httpx.AsyncClient", return_value=mock_client)
101+
async def test_check_repo_exists(
102+
git_command_succeeds: bool, # noqa: FBT001
103+
*,
104+
expected: bool,
105+
mocker: MockerFixture,
106+
) -> None:
107+
"""Verify that ``check_repo_exists`` interprets git ls-remote results correctly."""
108+
mock_git = mocker.patch("git.Git")
109+
mock_git_instance = mock_git.return_value
110+
111+
if git_command_succeeds:
112+
mock_git_instance.ls_remote.return_value = "abc123\trefs/heads/main\n"
113+
else:
114+
mock_git_instance.ls_remote.side_effect = git.GitCommandError("ls-remote", 128)
110115

111116
result = await check_repo_exists(DEMO_URL)
112117

113118
assert result is expected
119+
mock_git_instance.ls_remote.assert_called_once_with(DEMO_URL, "--exit-code")
114120

115121

116122
@pytest.mark.asyncio
@@ -202,19 +208,27 @@ async def test_clone_with_include_submodules(gitpython_mocks: dict) -> None:
202208

203209

204210
@pytest.mark.asyncio
205-
async def test_check_repo_exists_with_redirect(mocker: MockerFixture) -> None:
206-
"""Test ``check_repo_exists`` when a redirect (302) is returned.
211+
async def test_check_repo_exists_with_auth_token(mocker: MockerFixture) -> None:
212+
"""Test ``check_repo_exists`` with authentication token.
207213
208-
Given a URL that responds with "302 Found":
214+
Given a GitHub URL and a token:
209215
When ``check_repo_exists`` is called,
210-
Then it should return ``False``, indicating the repo is inaccessible.
216+
Then it should add the token to the URL and call git ls-remote.
211217
"""
212-
mock_exec = mocker.patch("asyncio.create_subprocess_exec", new_callable=AsyncMock)
213-
mock_process = AsyncMock()
214-
mock_process.communicate.return_value = (b"302\n", b"")
215-
mock_process.returncode = 0 # Simulate successful request
216-
mock_exec.return_value = mock_process
217-
218-
repo_exists = await check_repo_exists(DEMO_URL)
219-
220-
assert repo_exists is False
218+
mock_git = mocker.patch("git.Git")
219+
mock_git_instance = mock_git.return_value
220+
mock_git_instance.ls_remote.return_value = "abc123\trefs/heads/main\n"
221+
222+
# Mock the _add_token_to_url function
223+
mock_add_token = mocker.patch("gitingest.utils.git_utils._add_token_to_url")
224+
mock_add_token.return_value = "https://x-oauth-basic:token123@github.com/test/repo"
225+
226+
test_token = "token123" # noqa: S105
227+
result = await check_repo_exists("https://github.com/test/repo", token=test_token)
228+
229+
assert result is True
230+
mock_add_token.assert_called_once_with("https://github.com/test/repo", "token123")
231+
mock_git_instance.ls_remote.assert_called_once_with(
232+
"https://x-oauth-basic:token123@github.com/test/repo",
233+
"--exit-code",
234+
)

0 commit comments

Comments
 (0)