From 5e62ba9c48baf6f887eff7e4d96020f7f6ba3501 Mon Sep 17 00:00:00 2001
From: Timo Hennig <31155789+TheRealTimo@users.noreply.github.com>
Date: Sun, 6 Apr 2025 19:38:35 +0200
Subject: [PATCH 1/4] Add custom auth token for GitHub, gitlab etc.

---
 README.md                                     |  44 ++-
 requirements-dev.txt                          |   1 +
 src/gitingest/cli.py                          |  19 +-
 src/gitingest/cloning.py                      |  50 ++-
 src/gitingest/entrypoint.py                   |   9 +-
 src/server/query_processor.py                 |  51 ++-
 src/server/routers/dynamic.py                 |   3 +
 src/server/routers/index.py                   |   3 +
 .../templates/components/git_form.jinja       | 110 ++++---
 src/static/js/utils.js                        |  15 +-
 tests/test_cli.py                             |  74 ++++-
 tests/test_repository_clone.py                | 304 ++++++++++++++----
 12 files changed, 531 insertions(+), 152 deletions(-)

diff --git a/README.md b/README.md
index 38f235f6..d8ef0eff 100644
--- a/README.md
+++ b/README.md
@@ -65,16 +65,37 @@ gitingest --help
 
 This will write the digest in a text file (default `digest.txt`) in your current working directory.
 
+### Accessing Private Repositories with Tokens
+
+You can provide a Personal Access Token (PAT) to clone private repositories from supported platforms (GitHub, GitLab, Codeberg, Bitbucket).
+**Important:** This token is used **only** for the clone operation and is **never stored or logged** by Gitingest.
+
+1.  **Generate a Token:** Go to your Git provider's settings (e.g., GitHub Developer settings) and generate a Personal Access Token. Grant it the minimum required scope, which is typically read access to repositories (e.g., `repo` scope on GitHub, `read_repository` on GitLab).
+2.  **Use the Token (CLI):** Pass the token using the `--access-token` option:
+    ```bash
+    gitingest https://github.com/your-user/your-private-repo --access-token YOUR_TOKEN
+    gitingest https://gitlab.com/your-group/your-private-repo --access-token YOUR_TOKEN
+    ```
+    *Security Note:* Be mindful of your shell history when passing tokens directly on the command line.
+3.  **Use the Token (Web UI):** Paste the token into the "Access Token (Optional, for private repos)" field on [gitingest.com](https://gitingest.com) or your self-hosted instance.
+
+*Note: If using a token with an unsupported Git host, the token will be ignored.* 
+
 ## 🐍 Python package usage
 
 ```python
 # Synchronous usage
 from gitingest import ingest
 
-summary, tree, content = ingest("path/to/directory")
+# Public repo or local path
+summary, tree, content = ingest(\"path/to/directory\")
+summary, tree, content = ingest(\"https://github.com/cyclotruc/gitingest\")
 
-# or from URL
-summary, tree, content = ingest("https://github.com/cyclotruc/gitingest")
+# Private repo with token
+summary, tree, content = ingest(
+    \"https://github.com/your-user/your-private-repo\", 
+    access_token=\"YOUR_TOKEN\"
+)
 ```
 
 By default, this won't write a file but can be enabled with the `output` argument.
@@ -84,7 +105,13 @@ By default, this won't write a file but can be enabled with the `output` argumen
 from gitingest import ingest_async
 import asyncio
 
-result = asyncio.run(ingest_async("path/to/directory"))
+# Public repo or local path
+result = asyncio.run(ingest_async(\"path/to/directory\"))
+
+# Private repo with token
+summary, tree, content = asyncio.run(
+    ingest_async(\"https://gitlab.com/your-group/your-private-repo\", access_token=\"YOUR_TOKEN\")
+)
 ```
 
 ### Jupyter notebook usage
@@ -92,9 +119,14 @@ result = asyncio.run(ingest_async("path/to/directory"))
 ```python
 from gitingest import ingest_async
 
-# Use await directly in Jupyter
-summary, tree, content = await ingest_async("path/to/directory")
+# Public repo or local path
+summary, tree, content = await ingest_async(\"path/to/directory\")
 
+# Private repo with token (use await directly in Jupyter)
+summary, tree, content = await ingest_async(
+    \"https://github.com/your-user/your-private-repo\", 
+    access_token=\"YOUR_TOKEN\"
+)
 ```
 
 This is because Jupyter notebooks are asynchronous by default.
diff --git a/requirements-dev.txt b/requirements-dev.txt
index eb733ff3..b8fd868a 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -5,3 +5,4 @@ pre-commit
 pylint
 pytest
 pytest-asyncio
+pytest-mock
diff --git a/src/gitingest/cli.py b/src/gitingest/cli.py
index b691fd7f..a9be169b 100644
--- a/src/gitingest/cli.py
+++ b/src/gitingest/cli.py
@@ -18,6 +18,7 @@
 @click.option("--exclude-pattern", "-e", multiple=True, help="Patterns to exclude")
 @click.option("--include-pattern", "-i", multiple=True, help="Patterns to include")
 @click.option("--branch", "-b", default=None, help="Branch to clone and ingest")
+@click.option("--access-token", default=None, help="Access token for private repositories (e.g., GitHub, GitLab)")
 def main(
     source: str,
     output: Optional[str],
@@ -25,6 +26,7 @@ def main(
     exclude_pattern: Tuple[str, ...],
     include_pattern: Tuple[str, ...],
     branch: Optional[str],
+    access_token: Optional[str],
 ):
     """
      Main entry point for the CLI. This function is called when the CLI is run as a script.
@@ -46,9 +48,11 @@ def main(
         A tuple of patterns to include during the analysis. Only files matching these patterns will be processed.
     branch : str, optional
         The branch to clone (optional).
+    access_token : str, optional
+        Access token for private repositories (optional).
     """
     # Main entry point for the CLI. This function is called when the CLI is run as a script.
-    asyncio.run(_async_main(source, output, max_size, exclude_pattern, include_pattern, branch))
+    asyncio.run(_async_main(source, output, max_size, exclude_pattern, include_pattern, branch, access_token))
 
 
 async def _async_main(
@@ -58,6 +62,7 @@ async def _async_main(
     exclude_pattern: Tuple[str, ...],
     include_pattern: Tuple[str, ...],
     branch: Optional[str],
+    access_token: Optional[str],
 ) -> None:
     """
     Analyze a directory or repository and create a text dump of its contents.
@@ -80,6 +85,8 @@ async def _async_main(
         A tuple of patterns to include during the analysis. Only files matching these patterns will be processed.
     branch : str, optional
         The branch to clone (optional).
+    access_token : str, optional
+        Access token for private repositories (optional).
 
     Raises
     ------
@@ -93,7 +100,15 @@ async def _async_main(
 
         if not output:
             output = OUTPUT_FILE_NAME
-        summary, _, _ = await ingest_async(source, max_size, include_patterns, exclude_patterns, branch, output=output)
+        summary, _, _ = await ingest_async(
+            source=source,
+            max_file_size=max_size,
+            include_patterns=include_patterns,
+            exclude_patterns=exclude_patterns,
+            branch=branch,
+            output=output,
+            access_token=access_token,
+        )
 
         click.echo(f"Analysis complete! Output written to: {output}")
         click.echo("\nSummary:")
diff --git a/src/gitingest/cloning.py b/src/gitingest/cloning.py
index 79b97cb9..0b8116f3 100644
--- a/src/gitingest/cloning.py
+++ b/src/gitingest/cloning.py
@@ -3,6 +3,7 @@
 import os
 from pathlib import Path
 from typing import Optional
+from urllib.parse import urlparse
 
 from gitingest.schemas import CloneConfig
 from gitingest.utils.git_utils import check_repo_exists, ensure_git_installed, run_command
@@ -10,9 +11,18 @@
 
 TIMEOUT: int = 60
 
+# Known hosts and their token authentication methods (add more as needed)
+# Method: 'prefix' (https://<token>@host/...), 'oauth2' (https://oauth2:<token>@host/...), 'user' (<user>:<token>@host - requires username, not implemented)
+KNOWN_HOST_AUTH = {
+    "github.com": {"method": "prefix"},
+    "gitlab.com": {"method": "oauth2"},
+    "codeberg.org": {"method": "prefix"}, # Gitea instances often use prefix
+    "bitbucket.org": {"method": "prefix", "user": "x-token-auth"}, # Bitbucket uses x-token-auth:<token>
+}
+
 
 @async_timeout(TIMEOUT)
-async def clone_repo(config: CloneConfig) -> None:
+async def clone_repo(config: CloneConfig, access_token: Optional[str] = None) -> None:
     """
     Clone a repository to a local path based on the provided configuration.
 
@@ -24,6 +34,8 @@ async def clone_repo(config: CloneConfig) -> None:
     ----------
     config : CloneConfig
         The configuration for cloning the repository.
+    access_token : str, optional
+        Access token for private repositories (optional).
 
     Raises
     ------
@@ -46,9 +58,37 @@ async def clone_repo(config: CloneConfig) -> None:
     except OSError as exc:
         raise OSError(f"Failed to create parent directory {parent_dir}: {exc}") from exc
 
-    # Check if the repository exists
-    if not await check_repo_exists(url):
-        raise ValueError("Repository not found, make sure it is public")
+    # Construct authenticated URL based on host if token is provided
+    auth_url = url
+    should_skip_check = False
+    if access_token:
+        try:
+            parsed_url = urlparse(url)
+            hostname = parsed_url.netloc.lower()
+            host_info = KNOWN_HOST_AUTH.get(hostname)
+
+            if host_info:
+                method = host_info["method"]
+                if method == "prefix":
+                    user = host_info.get("user")
+                    if user:
+                        auth_url = url.replace("https://", f"https://{user}:{access_token}@")
+                    else:
+                        auth_url = url.replace("https://", f"https://{access_token}@")
+                elif method == "oauth2":
+                    auth_url = url.replace("https://", f"https://oauth2:{access_token}@")
+                # Add other methods if needed
+
+                should_skip_check = True # Skip check if token is provided for a known host
+
+        except Exception as e:
+            # Ignore parsing errors, proceed with original URL
+            print(f"Warning: Could not parse URL '{url}' for token auth: {e}")
+
+    # Skip existence check only if token is provided for a known host type
+    if not should_skip_check:
+        if not await check_repo_exists(url):
+            raise ValueError("Repository not found or inaccessible. If private, provide a token.")
 
     clone_cmd = ["git", "clone", "--single-branch"]
     # TODO re-enable --recurse-submodules
@@ -61,7 +101,7 @@ async def clone_repo(config: CloneConfig) -> None:
         if branch and branch.lower() not in ("main", "master"):
             clone_cmd += ["--branch", branch]
 
-    clone_cmd += [url, local_path]
+    clone_cmd += [auth_url, local_path]
 
     # Clone the repository
     await ensure_git_installed()
diff --git a/src/gitingest/entrypoint.py b/src/gitingest/entrypoint.py
index 0af4a4ba..80e43d57 100644
--- a/src/gitingest/entrypoint.py
+++ b/src/gitingest/entrypoint.py
@@ -18,6 +18,7 @@ async def ingest_async(
     exclude_patterns: Optional[Union[str, Set[str]]] = None,
     branch: Optional[str] = None,
     output: Optional[str] = None,
+    access_token: Optional[str] = None,
 ) -> Tuple[str, str, str]:
     """
     Main entry point for ingesting a source and processing its contents.
@@ -41,6 +42,8 @@ async def ingest_async(
         The branch to clone and ingest. If `None`, the default branch is used.
     output : str, optional
         File path where the summary and content should be written. If `None`, the results are not written to a file.
+    access_token : str, optional
+        Access token for private repositories (optional).
 
     Returns
     -------
@@ -71,7 +74,7 @@ async def ingest_async(
             query.branch = selected_branch
 
             clone_config = query.extract_clone_config()
-            clone_coroutine = clone_repo(clone_config)
+            clone_coroutine = clone_repo(clone_config, access_token=access_token)
 
             if inspect.iscoroutine(clone_coroutine):
                 if asyncio.get_event_loop().is_running():
@@ -103,6 +106,7 @@ def ingest(
     exclude_patterns: Optional[Union[str, Set[str]]] = None,
     branch: Optional[str] = None,
     output: Optional[str] = None,
+    access_token: Optional[str] = None,
 ) -> Tuple[str, str, str]:
     """
     Synchronous version of ingest_async.
@@ -126,6 +130,8 @@ def ingest(
         The branch to clone and ingest. If `None`, the default branch is used.
     output : str, optional
         File path where the summary and content should be written. If `None`, the results are not written to a file.
+    access_token : str, optional
+        Access token for private repositories (optional).
 
     Returns
     -------
@@ -147,5 +153,6 @@ def ingest(
             exclude_patterns=exclude_patterns,
             branch=branch,
             output=output,
+            access_token=access_token,
         )
     )
diff --git a/src/server/query_processor.py b/src/server/query_processor.py
index 00b1c640..ce6486de 100644
--- a/src/server/query_processor.py
+++ b/src/server/query_processor.py
@@ -1,6 +1,7 @@
 """Process a query by parsing input, cloning a repository, and generating a summary."""
 
 from functools import partial
+from typing import Optional
 
 from fastapi import Request
 from starlette.templating import _TemplateResponse
@@ -19,6 +20,7 @@ async def process_query(
     pattern_type: str = "exclude",
     pattern: str = "",
     is_index: bool = False,
+    access_token: Optional[str] = None,
 ) -> _TemplateResponse:
     """
     Process a query by parsing input, cloning a repository, and generating a summary.
@@ -40,6 +42,8 @@ async def process_query(
         Pattern to include or exclude in the query, depending on the pattern type.
     is_index : bool
         Flag indicating whether the request is for the index page (default is False).
+    access_token : str, optional
+        Access token for private repositories (optional).
 
     Returns
     -------
@@ -85,23 +89,36 @@ async def process_query(
             raise ValueError("The 'url' parameter is required.")
 
         clone_config = query.extract_clone_config()
-        await clone_repo(clone_config)
+        await clone_repo(clone_config, access_token=access_token)
         summary, tree, content = ingest_query(query)
         with open(f"{clone_config.local_path}.txt", "w", encoding="utf-8") as f:
             f.write(tree + "\n" + content)
     except Exception as exc:
         # hack to print error message when query is not defined
         if "query" in locals() and query is not None and isinstance(query, dict):
-            _print_error(query["url"], exc, max_file_size, pattern_type, pattern)
+            _print_error(query["url"], exc, max_file_size, pattern_type, pattern, access_token)
         else:
             print(f"{Colors.BROWN}WARN{Colors.END}: {Colors.RED}<-  {Colors.END}", end="")
             print(f"{Colors.RED}{exc}{Colors.END}")
 
-        context["error_message"] = f"Error: {exc}"
-        if "405" in str(exc):
-            context["error_message"] = (
-                "Repository not found. Please make sure it is public (private repositories will be supported soon)"
-            )
+        error_message = str(exc)
+        user_facing_error = f"An unexpected error occurred: {error_message}" # Default
+
+        # Check for specific, common errors to provide better messages
+        if isinstance(exc, ValueError) and "Repository not found or inaccessible" in error_message:
+            user_facing_error = error_message # Use the specific message from cloning.py
+        elif isinstance(exc, RuntimeError) and "Authentication failed" in error_message:
+            user_facing_error = "Authentication failed. Please check your token and repository permissions."
+        elif isinstance(exc, RuntimeError) and "could not read Username" in error_message:
+             user_facing_error = "Authentication failed. Please check your token and repository permissions."
+        elif isinstance(exc, TimeoutError) or "timed out" in error_message:
+             user_facing_error = "Cloning timed out. The repository might be too large or the network slow."
+        elif isinstance(exc, ValueError) and "Invalid repository URL" in error_message:
+            user_facing_error = "Invalid repository URL format."
+        elif isinstance(exc, FileNotFoundError):
+            user_facing_error = "Ingestion failed: Cloned repository directory not found."
+
+        context["error_message"] = user_facing_error
         return template_response(context=context)
 
     if len(content) > MAX_DISPLAY_SIZE:
@@ -116,6 +133,7 @@ async def process_query(
         pattern_type=pattern_type,
         pattern=pattern,
         summary=summary,
+        access_token=access_token,
     )
 
     context.update(
@@ -131,7 +149,7 @@ async def process_query(
     return template_response(context=context)
 
 
-def _print_query(url: str, max_file_size: int, pattern_type: str, pattern: str) -> None:
+def _print_query(url: str, max_file_size: int, pattern_type: str, pattern: str, access_token: Optional[str] = None) -> None:
     """
     Print a formatted summary of the query details, including the URL, file size,
     and pattern information, for easier debugging or logging.
@@ -146,6 +164,8 @@ def _print_query(url: str, max_file_size: int, pattern_type: str, pattern: str)
         Specifies the type of pattern to use, either "include" or "exclude".
     pattern : str
         The actual pattern string to include or exclude in the query.
+    access_token : str, optional
+        Access token (NOT logged for security).
     """
     print(f"{Colors.WHITE}{url:<20}{Colors.END}", end="")
     if int(max_file_size / 1024) != 50:
@@ -154,9 +174,12 @@ def _print_query(url: str, max_file_size: int, pattern_type: str, pattern: str)
         print(f" | {Colors.YELLOW}Include {pattern}{Colors.END}", end="")
     elif pattern_type == "exclude" and pattern != "":
         print(f" | {Colors.YELLOW}Exclude {pattern}{Colors.END}", end="")
+    # Ensure token is never printed
+    if access_token:
+        print(f" | {Colors.YELLOW}Token: [REDACTED]{Colors.END}", end="")
 
 
-def _print_error(url: str, e: Exception, max_file_size: int, pattern_type: str, pattern: str) -> None:
+def _print_error(url: str, e: Exception, max_file_size: int, pattern_type: str, pattern: str, access_token: Optional[str] = None) -> None:
     """
     Print a formatted error message including the URL, file size, pattern details, and the exception encountered,
     for debugging or logging purposes.
@@ -173,13 +196,15 @@ def _print_error(url: str, e: Exception, max_file_size: int, pattern_type: str,
         Specifies the type of pattern to use, either "include" or "exclude".
     pattern : str
         The actual pattern string to include or exclude in the query.
+    access_token : str, optional
+        Access token (NOT logged for security).
     """
     print(f"{Colors.BROWN}WARN{Colors.END}: {Colors.RED}<-  {Colors.END}", end="")
-    _print_query(url, max_file_size, pattern_type, pattern)
+    _print_query(url, max_file_size, pattern_type, pattern, access_token)
     print(f" | {Colors.RED}{e}{Colors.END}")
 
 
-def _print_success(url: str, max_file_size: int, pattern_type: str, pattern: str, summary: str) -> None:
+def _print_success(url: str, max_file_size: int, pattern_type: str, pattern: str, summary: str, access_token: Optional[str] = None) -> None:
     """
     Print a formatted success message, including the URL, file size, pattern details, and a summary with estimated
     tokens, for debugging or logging purposes.
@@ -196,8 +221,10 @@ def _print_success(url: str, max_file_size: int, pattern_type: str, pattern: str
         The actual pattern string to include or exclude in the query.
     summary : str
         A summary of the query result, including details like estimated tokens.
+    access_token : str, optional
+        Access token (NOT logged for security).
     """
     estimated_tokens = summary[summary.index("Estimated tokens:") + len("Estimated ") :]
     print(f"{Colors.GREEN}INFO{Colors.END}: {Colors.GREEN}<-  {Colors.END}", end="")
-    _print_query(url, max_file_size, pattern_type, pattern)
+    _print_query(url, max_file_size, pattern_type, pattern, access_token)
     print(f" | {Colors.PURPLE}{estimated_tokens}{Colors.END}")
diff --git a/src/server/routers/dynamic.py b/src/server/routers/dynamic.py
index bfa31f68..948130fa 100644
--- a/src/server/routers/dynamic.py
+++ b/src/server/routers/dynamic.py
@@ -2,6 +2,7 @@
 
 from fastapi import APIRouter, Form, Request
 from fastapi.responses import HTMLResponse
+from typing import Optional
 
 from server.query_processor import process_query
 from server.server_config import templates
@@ -50,6 +51,7 @@ async def process_catch_all(
     max_file_size: int = Form(...),
     pattern_type: str = Form(...),
     pattern: str = Form(...),
+    access_token: Optional[str] = Form(None),
 ) -> HTMLResponse:
     """
     Process the form submission with user input for query parameters.
@@ -83,4 +85,5 @@ async def process_catch_all(
         pattern_type,
         pattern,
         is_index=False,
+        access_token=access_token,
     )
diff --git a/src/server/routers/index.py b/src/server/routers/index.py
index 01b84730..a94e2f85 100644
--- a/src/server/routers/index.py
+++ b/src/server/routers/index.py
@@ -2,6 +2,7 @@
 
 from fastapi import APIRouter, Form, Request
 from fastapi.responses import HTMLResponse
+from typing import Optional
 
 from server.query_processor import process_query
 from server.server_config import EXAMPLE_REPOS, templates
@@ -47,6 +48,7 @@ async def index_post(
     max_file_size: int = Form(...),
     pattern_type: str = Form(...),
     pattern: str = Form(...),
+    access_token: Optional[str] = Form(None),
 ) -> HTMLResponse:
     """
     Process the form submission with user input for query parameters.
@@ -81,4 +83,5 @@ async def index_post(
         pattern_type,
         pattern,
         is_index=True,
+        access_token=access_token,
     )
diff --git a/src/server/templates/components/git_form.jinja b/src/server/templates/components/git_form.jinja
index 764fff70..b1fd7c20 100644
--- a/src/server/templates/components/git_form.jinja
+++ b/src/server/templates/components/git_form.jinja
@@ -45,58 +45,72 @@
             </div>
             <input type="hidden" name="pattern_type" value="exclude">
             <input type="hidden" name="pattern" value="">
-        </form>
-        <div class="mt-4 relative z-20 flex flex-wrap gap-4 items-start">
-            <!-- Pattern selector -->
-            <div class="w-[200px] sm:w-[250px] mr-9 mt-4">
-                <div class="relative">
-                    <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0 z-10"></div>
-                    <div class="flex relative z-20 border-[3px] border-gray-900 rounded bg-white">
-                        <div class="relative flex items-center">
-                            <select id="pattern_type"
-                                    onchange="changePattern(this)"
-                                    name="pattern_type"
-                                    class="w-21 py-2 pl-2 pr-6 appearance-none bg-[#e6e8eb] focus:outline-none border-r-[3px] border-gray-900">
-                                <option value="exclude"
-                                        {% if pattern_type == 'exclude' or not pattern_type %}selected{% endif %}>
-                                    Exclude
-                                </option>
-                                <option value="include" {% if pattern_type == 'include' %}selected{% endif %}>Include</option>
-                            </select>
-                            <svg class="absolute right-2 w-4 h-4 pointer-events-none"
-                                 xmlns="http://www.w3.org/2000/svg"
-                                 viewBox="0 0 24 24"
-                                 fill="none"
-                                 stroke="currentColor"
-                                 stroke-width="2"
-                                 stroke-linecap="round"
-                                 stroke-linejoin="round">
-                                <polyline points="6 9 12 15 18 9" />
-                            </svg>
+            <div class="mt-4 relative z-20 flex flex-wrap gap-4 items-start">
+                <!-- Pattern selector -->
+                <div class="w-[200px] sm:w-[250px] mr-9 mt-4">
+                    <div class="relative">
+                        <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0 z-10"></div>
+                        <div class="flex relative z-20 border-[3px] border-gray-900 rounded bg-white">
+                            <div class="relative flex items-center">
+                                <select id="pattern_type"
+                                        onchange="changePattern(this)"
+                                        name="pattern_type"
+                                        class="w-21 py-2 pl-2 pr-6 appearance-none bg-[#e6e8eb] focus:outline-none border-r-[3px] border-gray-900">
+                                    <option value="exclude"
+                                            {% if pattern_type == 'exclude' or not pattern_type %}selected{% endif %}>
+                                        Exclude
+                                    </option>
+                                    <option value="include" {% if pattern_type == 'include' %}selected{% endif %}>Include</option>
+                                </select>
+                                <svg class="absolute right-2 w-4 h-4 pointer-events-none"
+                                     xmlns="http://www.w3.org/2000/svg"
+                                     viewBox="0 0 24 24"
+                                     fill="none"
+                                     stroke="currentColor"
+                                     stroke-width="2"
+                                     stroke-linecap="round"
+                                     stroke-linejoin="round">
+                                    <polyline points="6 9 12 15 18 9" />
+                                </svg>
+                            </div>
+                            <input type="text"
+                                   id="pattern"
+                                   name="pattern"
+                                   placeholder="*.md, src/ "
+                                   value="{{ pattern if pattern else '' }}"
+                                   class=" py-2 px-2 bg-[#E8F0FE] focus:outline-none w-full">
                         </div>
-                        <input type="text"
-                               id="pattern"
-                               name="pattern"
-                               placeholder="*.md, src/ "
-                               value="{{ pattern if pattern else '' }}"
-                               class=" py-2 px-2 bg-[#E8F0FE] focus:outline-none w-full">
+                    </div>
+                </div>
+                <div class="w-[200px] sm:w-[200px] mt-3">
+                    <label for="file_size" class="block text-gray-700 mb-1">
+                        Include files under: <span id="size_value" class="font-bold">50kb</span>
+                    </label>
+                    <input type="range"
+                           id="file_size"
+                           name="max_file_size"
+                           min="0"
+                           max="500"
+                           required
+                           value="{{ default_file_size }}"
+                           class="w-full h-3 bg-[#FAFAFA] bg-no-repeat bg-[length:50%_100%] bg-[#ebdbb7] appearance-none border-[3px] border-gray-900 rounded-sm focus:outline-none bg-gradient-to-r from-[#FE4A60] to-[#FE4A60] [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-7 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-solid [&::-webkit-slider-thumb]:border-[3px] [&::-webkit-slider-thumb]:border-gray-900 [&::-webkit-slider-thumb]:shadow-[3px_3px_0_#000]  ">
+                </div>
+                <!-- GitHub Token Input -->
+                <div class="w-full sm:w-[300px] mt-3">
+                    <label for="access_token" class="block text-gray-700 mb-1">
+                        Access Token (Optional, for private repos)
+                    </label>
+                    <div class="relative">
+                        <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0 z-10"></div>
+                        <input type="password"
+                               id="access_token"
+                               name="access_token"
+                               placeholder="ghp_..., glpat_..., etc."
+                               class="w-full relative z-20 py-2 px-2 bg-[#E8F0FE] focus:outline-none border-[3px] border-gray-900 rounded">
                     </div>
                 </div>
             </div>
-            <div class="w-[200px] sm:w-[200px] mt-3">
-                <label for="file_size" class="block text-gray-700 mb-1">
-                    Include files under: <span id="size_value" class="font-bold">50kb</span>
-                </label>
-                <input type="range"
-                       id="file_size"
-                       name="max_file_size"
-                       min="0"
-                       max="500"
-                       required
-                       value="{{ default_file_size }}"
-                       class="w-full h-3 bg-[#FAFAFA] bg-no-repeat bg-[length:50%_100%] bg-[#ebdbb7] appearance-none border-[3px] border-gray-900 rounded-sm focus:outline-none bg-gradient-to-r from-[#FE4A60] to-[#FE4A60] [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-7 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-sm [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-solid [&::-webkit-slider-thumb]:border-[3px] [&::-webkit-slider-thumb]:border-gray-900 [&::-webkit-slider-thumb]:shadow-[3px_3px_0_#000]  ">
-            </div>
-        </div>
+        </form>
         {% if show_examples %}
             <!-- Example repositories section -->
             <div class="mt-4">
diff --git a/src/static/js/utils.js b/src/static/js/utils.js
index be74c916..dc8a57f3 100644
--- a/src/static/js/utils.js
+++ b/src/static/js/utils.js
@@ -99,10 +99,17 @@ function handleSubmit(event, showLoading = false) {
             // Replace the entire body content with the new HTML
             document.body.innerHTML = html;
 
-            // Wait for next tick to ensure DOM is updated
+            // Wait for next tick to ensure DOM is updated and scripts might re-run
             setTimeout(() => {
-                // Reinitialize slider functionality
-                initializeSlider();
+                // Explicitly re-run initialization for slider and enter key handler
+                // These might be necessary if the new HTML includes the script tags again
+                // or if the previous handlers were somehow detached.
+                if (typeof initializeSlider === 'function') {
+                    initializeSlider();
+                }
+                if (typeof setupGlobalEnterHandler === 'function') {
+                    setupGlobalEnterHandler(); // Re-attach enter key listener if needed
+                }
 
                 const starsElement = document.getElementById('github-stars');
                 if (starsElement && starCount) {
@@ -206,7 +213,7 @@ function setupGlobalEnterHandler() {
     });
 }
 
-// Add to the DOMContentLoaded event listener
+// Ensure event listeners are attached after DOM is ready
 document.addEventListener('DOMContentLoaded', () => {
     initializeSlider();
     setupGlobalEnterHandler();
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 7eadea46..bd83d32b 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,41 +1,93 @@
 """Tests for the gitingest cli."""
 
 import os
+import pytest
+from unittest.mock import AsyncMock
 
 from click.testing import CliRunner
 
 from gitingest.cli import main
 from gitingest.config import MAX_FILE_SIZE, OUTPUT_FILE_NAME
 
+# Mock the ingest_async function to avoid actual processing
+@pytest.fixture(autouse=True)
+def mock_ingest_async(mocker):
+    mock = mocker.patch("gitingest.cli.ingest_async", new_callable=AsyncMock)
+    mock.return_value = ("summary", "tree", "content")
+    return mock
 
-def test_cli_with_default_options():
+
+def test_cli_with_default_options(mock_ingest_async):
     runner = CliRunner()
     result = runner.invoke(main, ["./"])
+    assert result.exit_code == 0
+    # Use keyword args only, check source explicitly
+    call_args, call_kwargs = mock_ingest_async.await_args
+    assert call_args == ()
+    assert call_kwargs.get("source") in ('.', './')
+    assert call_kwargs == {
+        "source": call_kwargs.get("source"), # Keep the actual source value
+        "max_file_size": MAX_FILE_SIZE,
+        "include_patterns": set(),
+        "exclude_patterns": set(),
+        "branch": None,
+        "output": OUTPUT_FILE_NAME, # Check default output name
+        "access_token": None, # Renamed token param
+    }
+
     output_lines = result.output.strip().split("\n")
     assert f"Analysis complete! Output written to: {OUTPUT_FILE_NAME}" in output_lines
-    assert os.path.exists(OUTPUT_FILE_NAME), f"Output file was not created at {OUTPUT_FILE_NAME}"
+    # assert os.path.exists(OUTPUT_FILE_NAME), f"Output file was not created at {OUTPUT_FILE_NAME}" # Remove check
 
-    os.remove(OUTPUT_FILE_NAME)
+    # Clean up: Remove the default output file if created by the mocked logic (or original if mock fails)
+    # if os.path.exists(OUTPUT_FILE_NAME): # Remove cleanup
+    #     os.remove(OUTPUT_FILE_NAME)
 
 
-def test_cli_with_options():
+def test_cli_with_options(mock_ingest_async):
     runner = CliRunner()
+    output_file = "test_output.txt"
+    exclude = "tests/"
+    include = "src/"
+    branch = "develop"
+    token = "test_token_123"
     result = runner.invoke(
         main,
         [
-            "./",
+            ".",
             "--output",
-            str(OUTPUT_FILE_NAME),
+            output_file,
             "--max-size",
             str(MAX_FILE_SIZE),
             "--exclude-pattern",
-            "tests/",
+            exclude,
             "--include-pattern",
-            "src/",
+            include,
+            "--branch",
+            branch,
+            "--access-token", # Renamed token param
+            token,
         ],
     )
+    assert result.exit_code == 0
+    # Use keyword args only, check source explicitly
+    call_args, call_kwargs = mock_ingest_async.await_args
+    assert call_args == ()
+    assert call_kwargs.get("source") in ('.', './')
+    assert call_kwargs == {
+        "source": call_kwargs.get("source"), # Keep the actual source value
+        "max_file_size": MAX_FILE_SIZE,
+        "include_patterns": {include},
+        "exclude_patterns": {exclude},
+        "branch": branch,
+        "output": output_file,
+        "access_token": token, # Renamed token param
+    }
+
     output_lines = result.output.strip().split("\n")
-    assert f"Analysis complete! Output written to: {OUTPUT_FILE_NAME}" in output_lines
-    assert os.path.exists(OUTPUT_FILE_NAME), f"Output file was not created at {OUTPUT_FILE_NAME}"
+    assert f"Analysis complete! Output written to: {output_file}" in output_lines
+    # assert os.path.exists(output_file), f"Output file was not created at {output_file}" # Remove check
 
-    os.remove(OUTPUT_FILE_NAME)
+    # Clean up
+    # if os.path.exists(output_file): # Remove cleanup
+    #     os.remove(output_file)
diff --git a/tests/test_repository_clone.py b/tests/test_repository_clone.py
index b614d5a4..dcb8c8cf 100644
--- a/tests/test_repository_clone.py
+++ b/tests/test_repository_clone.py
@@ -18,7 +18,7 @@
 
 
 @pytest.mark.asyncio
-async def test_clone_with_commit() -> None:
+async def test_clone_with_commit(mocker) -> None:
     """
     Test cloning a repository with a specific commit hash.
 
@@ -33,20 +33,20 @@ async def test_clone_with_commit() -> None:
         branch="main",
     )
 
-    with patch("gitingest.cloning.check_repo_exists", return_value=True) as mock_check:
-        with patch("gitingest.cloning.run_command", new_callable=AsyncMock) as mock_exec:
-            mock_process = AsyncMock()
-            mock_process.communicate.return_value = (b"output", b"error")
-            mock_exec.return_value = mock_process
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists", return_value=True)
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+    mock_process = AsyncMock()
+    mock_process.communicate.return_value = (b"output", b"error")
+    mock_exec.return_value = mock_process
 
-            await clone_repo(clone_config)
+    await clone_repo(clone_config)
 
-            mock_check.assert_called_once_with(clone_config.url)
-            assert mock_exec.call_count == 2  # Clone and checkout calls
+    mock_check.assert_called_once_with(clone_config.url)
+    assert mock_exec.call_count == 2  # Clone and checkout calls
 
 
 @pytest.mark.asyncio
-async def test_clone_without_commit() -> None:
+async def test_clone_without_commit(mocker) -> None:
     """
     Test cloning a repository when no commit hash is provided.
 
@@ -61,20 +61,20 @@ async def test_clone_without_commit() -> None:
         branch="main",
     )
 
-    with patch("gitingest.cloning.check_repo_exists", return_value=True) as mock_check:
-        with patch("gitingest.cloning.run_command", new_callable=AsyncMock) as mock_exec:
-            mock_process = AsyncMock()
-            mock_process.communicate.return_value = (b"output", b"error")
-            mock_exec.return_value = mock_process
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists", return_value=True)
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+    mock_process = AsyncMock()
+    mock_process.communicate.return_value = (b"output", b"error")
+    mock_exec.return_value = mock_process
 
-            await clone_repo(query)
+    await clone_repo(query)
 
-            mock_check.assert_called_once_with(query.url)
-            assert mock_exec.call_count == 1  # Only clone call
+    mock_check.assert_called_once_with(query.url)
+    assert mock_exec.call_count == 1  # Only clone call
 
 
 @pytest.mark.asyncio
-async def test_clone_nonexistent_repository() -> None:
+async def test_clone_nonexistent_repository(mocker) -> None:
     """
     Test cloning a nonexistent repository URL.
 
@@ -88,11 +88,11 @@ async def test_clone_nonexistent_repository() -> None:
         commit=None,
         branch="main",
     )
-    with patch("gitingest.cloning.check_repo_exists", return_value=False) as mock_check:
-        with pytest.raises(ValueError, match="Repository not found"):
-            await clone_repo(clone_config)
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists", return_value=False)
+    with pytest.raises(ValueError, match="Repository not found or inaccessible"):
+        await clone_repo(clone_config)
 
-            mock_check.assert_called_once_with(clone_config.url)
+        mock_check.assert_called_once_with(clone_config.url)
 
 
 @pytest.mark.asyncio
@@ -127,7 +127,7 @@ async def test_check_repo_exists(mock_stdout: bytes, return_code: int, expected:
 
 
 @pytest.mark.asyncio
-async def test_clone_with_custom_branch() -> None:
+async def test_clone_with_custom_branch(mocker) -> None:
     """
     Test cloning a repository with a specified custom branch.
 
@@ -136,24 +136,24 @@ async def test_clone_with_custom_branch() -> None:
     Then the repository should be cloned shallowly to that branch.
     """
     clone_config = CloneConfig(url="https://github.com/user/repo", local_path="/tmp/repo", branch="feature-branch")
-    with patch("gitingest.cloning.check_repo_exists", return_value=True):
-        with patch("gitingest.cloning.run_command", new_callable=AsyncMock) as mock_exec:
-            await clone_repo(clone_config)
-
-            mock_exec.assert_called_once_with(
-                "git",
-                "clone",
-                "--single-branch",
-                "--depth=1",
-                "--branch",
-                "feature-branch",
-                clone_config.url,
-                clone_config.local_path,
-            )
+    mocker.patch("gitingest.cloning.check_repo_exists", return_value=True)
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+    await clone_repo(clone_config)
+
+    mock_exec.assert_called_once_with(
+        "git",
+        "clone",
+        "--single-branch",
+        "--depth=1",
+        "--branch",
+        "feature-branch",
+        clone_config.url,
+        clone_config.local_path,
+    )
 
 
 @pytest.mark.asyncio
-async def test_git_command_failure() -> None:
+async def test_git_command_failure(mocker) -> None:
     """
     Test cloning when the Git command fails during execution.
 
@@ -165,14 +165,14 @@ async def test_git_command_failure() -> None:
         url="https://github.com/user/repo",
         local_path="/tmp/repo",
     )
-    with patch("gitingest.cloning.check_repo_exists", return_value=True):
-        with patch("gitingest.cloning.run_command", side_effect=RuntimeError("Git command failed")):
-            with pytest.raises(RuntimeError, match="Git command failed"):
-                await clone_repo(clone_config)
+    mocker.patch("gitingest.cloning.check_repo_exists", return_value=True)
+    mocker.patch("gitingest.cloning.run_command", side_effect=RuntimeError("Git command failed"))
+    with pytest.raises(RuntimeError, match="Git command failed"):
+        await clone_repo(clone_config)
 
 
 @pytest.mark.asyncio
-async def test_clone_default_shallow_clone() -> None:
+async def test_clone_default_shallow_clone(mocker) -> None:
     """
     Test cloning a repository with the default shallow clone options.
 
@@ -185,22 +185,22 @@ async def test_clone_default_shallow_clone() -> None:
         local_path="/tmp/repo",
     )
 
-    with patch("gitingest.cloning.check_repo_exists", return_value=True):
-        with patch("gitingest.cloning.run_command", new_callable=AsyncMock) as mock_exec:
-            await clone_repo(clone_config)
-
-            mock_exec.assert_called_once_with(
-                "git",
-                "clone",
-                "--single-branch",
-                "--depth=1",
-                clone_config.url,
-                clone_config.local_path,
-            )
+    mocker.patch("gitingest.cloning.check_repo_exists", return_value=True)
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+    await clone_repo(clone_config)
+
+    mock_exec.assert_called_once_with(
+        "git",
+        "clone",
+        "--single-branch",
+        "--depth=1",
+        clone_config.url,
+        clone_config.local_path,
+    )
 
 
 @pytest.mark.asyncio
-async def test_clone_commit_without_branch() -> None:
+async def test_clone_commit_without_branch(mocker) -> None:
     """
     Test cloning when a commit hash is provided but no branch is specified.
 
@@ -213,13 +213,13 @@ async def test_clone_commit_without_branch() -> None:
         local_path="/tmp/repo",
         commit="a" * 40,  # Simulating a valid commit hash
     )
-    with patch("gitingest.cloning.check_repo_exists", return_value=True):
-        with patch("gitingest.cloning.run_command", new_callable=AsyncMock) as mock_exec:
-            await clone_repo(clone_config)
+    mocker.patch("gitingest.cloning.check_repo_exists", return_value=True)
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+    await clone_repo(clone_config)
 
-            assert mock_exec.call_count == 2  # Clone and checkout calls
-            mock_exec.assert_any_call("git", "clone", "--single-branch", clone_config.url, clone_config.local_path)
-            mock_exec.assert_any_call("git", "-C", clone_config.local_path, "checkout", clone_config.commit)
+    assert mock_exec.call_count == 2  # Clone and checkout calls
+    mock_exec.assert_any_call("git", "clone", "--single-branch", clone_config.url, clone_config.local_path)
+    mock_exec.assert_any_call("git", "-C", clone_config.local_path, "checkout", clone_config.commit)
 
 
 @pytest.mark.asyncio
@@ -448,3 +448,181 @@ async def test_clone_with_commit_and_subpath() -> None:
             )
 
             assert mock_exec.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_clone_public_repo_with_token(mocker) -> None:
+    """
+    Test cloning a public repo with a token. check_repo_exists should be skipped.
+    """
+    token = "ghp_TestTokenValue12345"
+    url = "https://github.com/public/repo"
+    auth_url = f"https://{token}@github.com/public/repo"
+    clone_config = CloneConfig(url=url, local_path="/tmp/repo")
+
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists")
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+
+    await clone_repo(clone_config, access_token=token)
+
+    mock_check.assert_not_called()
+    mock_exec.assert_called_once_with(
+        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path
+    )
+
+
+@pytest.mark.asyncio
+async def test_clone_private_repo_with_valid_token(mocker) -> None:
+    """
+    Test cloning a private repo with a valid token.
+    """
+    token = "ghp_ValidPrivateToken123"
+    url = "https://github.com/private/repo"
+    auth_url = f"https://{token}@github.com/private/repo"
+    clone_config = CloneConfig(url=url, local_path="/tmp/private-repo")
+
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists")
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+
+    await clone_repo(clone_config, access_token=token)
+
+    mock_check.assert_not_called()
+    mock_exec.assert_called_once_with(
+        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path
+    )
+
+
+@pytest.mark.asyncio
+async def test_clone_private_repo_with_invalid_token(mocker, capsys) -> None:
+    """
+    Test cloning a private repo with an invalid token raises RuntimeError and doesn't log token.
+    """
+    token = "ghp_InvalidToken987"
+    url = "https://github.com/private/repo"
+    auth_url = f"https://{token}@github.com/private/repo"
+    clone_config = CloneConfig(url=url, local_path="/tmp/private-repo-fail")
+    error_message = "Authentication failed"
+
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists")
+    # Mock run_command to simulate auth failure
+    mock_exec = mocker.patch(
+        "gitingest.cloning.run_command",
+        side_effect=RuntimeError(f"Command failed: git clone ... {auth_url} ...\\nError: {error_message}")
+    )
+
+    with pytest.raises(RuntimeError, match=error_message):
+        await clone_repo(clone_config, access_token=token)
+
+    mock_check.assert_not_called()
+    mock_exec.assert_called_once_with(
+        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path
+    )
+
+    # Check that the token is not in the captured output (from the RuntimeError)
+    captured = capsys.readouterr()
+    assert token not in captured.out
+    assert token not in captured.err
+    # Ensure the auth_url itself isn't printed verbatim in the error
+    assert auth_url not in captured.out
+    assert auth_url not in captured.err
+
+
+@pytest.mark.asyncio
+async def test_clone_private_repo_without_token(mocker) -> None:
+    """
+    Test cloning a private repo without a token raises ValueError.
+    """
+    url = "https://github.com/private/repo-no-token"
+    clone_config = CloneConfig(url=url, local_path="/tmp/private-repo-no-token")
+
+    # Mock check_repo_exists to return False, as it would for a private repo
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists", return_value=False)
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+
+    with pytest.raises(ValueError, match="Repository not found or inaccessible. If private, provide a token."):
+        await clone_repo(clone_config, access_token=None)
+
+    mock_check.assert_called_once_with(url)
+    mock_exec.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_clone_non_github_repo_with_token(mocker) -> None:
+    """
+    Test cloning a non-GitHub repo with a token.
+    The URL should be modified for known hosts (GitLab, Bitbucket, Codeberg).
+    The existence check should be skipped.
+    """
+    token = "gitlab_TokenValue12345"
+    url = "https://gitlab.com/public/repo"
+    auth_url = f"https://oauth2:{token}@gitlab.com/public/repo" # GitLab format
+    clone_config = CloneConfig(url=url, local_path="/tmp/gitlab-repo")
+
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists")
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+
+    await clone_repo(clone_config, access_token=token) # Use access_token
+
+    mock_check.assert_not_called() # check_repo_exists should be skipped for known host w/ token
+    mock_exec.assert_called_once_with(
+        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path # Use GitLab auth URL
+    )
+
+
+@pytest.mark.asyncio
+async def test_clone_bitbucket_repo_with_token(mocker) -> None:
+    """Test cloning a Bitbucket repo with a token."""
+    token = "bitbucket_token_xyz"
+    url = "https://bitbucket.org/team/repo"
+    auth_url = f"https://x-token-auth:{token}@bitbucket.org/team/repo"
+    clone_config = CloneConfig(url=url, local_path="/tmp/bitbucket-repo")
+
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists")
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+
+    await clone_repo(clone_config, access_token=token)
+
+    mock_check.assert_not_called()
+    mock_exec.assert_called_once_with(
+        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path
+    )
+
+
+@pytest.mark.asyncio
+async def test_clone_codeberg_repo_with_token(mocker) -> None:
+    """Test cloning a Codeberg repo with a token."""
+    token = "codeberg_token_abc"
+    url = "https://codeberg.org/user/repo"
+    auth_url = f"https://{token}@codeberg.org/user/repo"
+    clone_config = CloneConfig(url=url, local_path="/tmp/codeberg-repo")
+
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists")
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+
+    await clone_repo(clone_config, access_token=token)
+
+    mock_check.assert_not_called()
+    mock_exec.assert_called_once_with(
+        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path
+    )
+
+
+@pytest.mark.asyncio
+async def test_clone_unknown_host_with_token(mocker) -> None:
+    """
+    Test cloning an unknown host with a token.
+    The URL should NOT be modified, and existence check should NOT be skipped.
+    """
+    token = "some_token"
+    url = "https://mygitserver.com/user/repo"
+    clone_config = CloneConfig(url=url, local_path="/tmp/unknown-repo")
+
+    mock_check = mocker.patch("gitingest.cloning.check_repo_exists", return_value=True)
+    mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
+
+    await clone_repo(clone_config, access_token=token)
+
+    mock_check.assert_called_once_with(url) # Existence check should be called
+    mock_exec.assert_called_once_with(
+        "git", "clone", "--single-branch", "--depth=1", url, clone_config.local_path # URL unchanged
+    )

From 9ce93edaa9ca51245aa918340f1deb985d0cfa2f Mon Sep 17 00:00:00 2001
From: Timo Hennig <31155789+TheRealTimo@users.noreply.github.com>
Date: Sun, 6 Apr 2025 19:47:44 +0200
Subject: [PATCH 2/4] Fixes after pre-commit

---
 README.md                                     | 24 +++++++-------
 src/gitingest/cloning.py                      | 11 ++++---
 src/server/query_processor.py                 | 21 ++++++++----
 src/server/routers/dynamic.py                 |  5 ++-
 src/server/routers/index.py                   |  5 ++-
 .../templates/components/git_form.jinja       |  4 +--
 tests/test_cli.py                             | 33 ++++++++++---------
 tests/test_repository_clone.py                | 14 ++++----
 8 files changed, 68 insertions(+), 49 deletions(-)

diff --git a/README.md b/README.md
index d8ef0eff..7d728edc 100644
--- a/README.md
+++ b/README.md
@@ -70,16 +70,18 @@ This will write the digest in a text file (default `digest.txt`) in your current
 You can provide a Personal Access Token (PAT) to clone private repositories from supported platforms (GitHub, GitLab, Codeberg, Bitbucket).
 **Important:** This token is used **only** for the clone operation and is **never stored or logged** by Gitingest.
 
-1.  **Generate a Token:** Go to your Git provider's settings (e.g., GitHub Developer settings) and generate a Personal Access Token. Grant it the minimum required scope, which is typically read access to repositories (e.g., `repo` scope on GitHub, `read_repository` on GitLab).
-2.  **Use the Token (CLI):** Pass the token using the `--access-token` option:
-    ```bash
-    gitingest https://github.com/your-user/your-private-repo --access-token YOUR_TOKEN
-    gitingest https://gitlab.com/your-group/your-private-repo --access-token YOUR_TOKEN
-    ```
-    *Security Note:* Be mindful of your shell history when passing tokens directly on the command line.
-3.  **Use the Token (Web UI):** Paste the token into the "Access Token (Optional, for private repos)" field on [gitingest.com](https://gitingest.com) or your self-hosted instance.
+1. **Generate a Token:** Go to your Git provider's settings (e.g., GitHub Developer settings) and generate a Personal Access Token. Grant it the minimum required scope, which is typically read access to repositories (e.g., `repo` scope on GitHub, `read_repository` on GitLab).
+2. **Use the Token (CLI):** Pass the token using the `--access-token` option:
 
-*Note: If using a token with an unsupported Git host, the token will be ignored.* 
+   ```bash
+   gitingest https://github.com/your-user/your-private-repo --access-token YOUR_TOKEN
+   gitingest https://gitlab.com/your-group/your-private-repo --access-token YOUR_TOKEN
+   ```
+
+   *Security Note:* Be mindful of your shell history when passing tokens directly on the command line.
+3. **Use the Token (Web UI):** Paste the token into the "Access Token (Optional, for private repos)" field on [gitingest.com](https://gitingest.com) or your self-hosted instance.
+
+*Note: If using a token with an unsupported Git host, the token will be ignored.*
 
 ## 🐍 Python package usage
 
@@ -93,7 +95,7 @@ summary, tree, content = ingest(\"https://github.com/cyclotruc/gitingest\")
 
 # Private repo with token
 summary, tree, content = ingest(
-    \"https://github.com/your-user/your-private-repo\", 
+    \"https://github.com/your-user/your-private-repo\",
     access_token=\"YOUR_TOKEN\"
 )
 ```
@@ -124,7 +126,7 @@ summary, tree, content = await ingest_async(\"path/to/directory\")
 
 # Private repo with token (use await directly in Jupyter)
 summary, tree, content = await ingest_async(
-    \"https://github.com/your-user/your-private-repo\", 
+    \"https://github.com/your-user/your-private-repo\",
     access_token=\"YOUR_TOKEN\"
 )
 ```
diff --git a/src/gitingest/cloning.py b/src/gitingest/cloning.py
index 0b8116f3..c36e205e 100644
--- a/src/gitingest/cloning.py
+++ b/src/gitingest/cloning.py
@@ -12,16 +12,19 @@
 TIMEOUT: int = 60
 
 # Known hosts and their token authentication methods (add more as needed)
-# Method: 'prefix' (https://<token>@host/...), 'oauth2' (https://oauth2:<token>@host/...), 'user' (<user>:<token>@host - requires username, not implemented)
+# Method: 'prefix' (https://<token>@host/...),
+#         'oauth2' (https://oauth2:<token>@host/...),
+#         'user' (<user>:<token>@host - requires username, not implemented)
 KNOWN_HOST_AUTH = {
     "github.com": {"method": "prefix"},
     "gitlab.com": {"method": "oauth2"},
-    "codeberg.org": {"method": "prefix"}, # Gitea instances often use prefix
-    "bitbucket.org": {"method": "prefix", "user": "x-token-auth"}, # Bitbucket uses x-token-auth:<token>
+    "codeberg.org": {"method": "prefix"},  # Gitea instances often use prefix
+    "bitbucket.org": {"method": "prefix", "user": "x-token-auth"},  # Bitbucket uses x-token-auth:<token>
 }
 
 
 @async_timeout(TIMEOUT)
+# pylint: disable=too-many-branches, too-many-statements
 async def clone_repo(config: CloneConfig, access_token: Optional[str] = None) -> None:
     """
     Clone a repository to a local path based on the provided configuration.
@@ -79,7 +82,7 @@ async def clone_repo(config: CloneConfig, access_token: Optional[str] = None) ->
                     auth_url = url.replace("https://", f"https://oauth2:{access_token}@")
                 # Add other methods if needed
 
-                should_skip_check = True # Skip check if token is provided for a known host
+                should_skip_check = True  # Skip check if token is provided for a known host
 
         except Exception as e:
             # Ignore parsing errors, proceed with original URL
diff --git a/src/server/query_processor.py b/src/server/query_processor.py
index ce6486de..0ec8ee45 100644
--- a/src/server/query_processor.py
+++ b/src/server/query_processor.py
@@ -13,6 +13,7 @@
 from server.server_utils import Colors, log_slider_to_size
 
 
+# pylint: disable=too-many-branches
 async def process_query(
     request: Request,
     input_text: str,
@@ -102,17 +103,17 @@ async def process_query(
             print(f"{Colors.RED}{exc}{Colors.END}")
 
         error_message = str(exc)
-        user_facing_error = f"An unexpected error occurred: {error_message}" # Default
+        user_facing_error = f"An unexpected error occurred: {error_message}"  # Default
 
         # Check for specific, common errors to provide better messages
         if isinstance(exc, ValueError) and "Repository not found or inaccessible" in error_message:
-            user_facing_error = error_message # Use the specific message from cloning.py
+            user_facing_error = error_message  # Use the specific message from cloning.py
         elif isinstance(exc, RuntimeError) and "Authentication failed" in error_message:
             user_facing_error = "Authentication failed. Please check your token and repository permissions."
         elif isinstance(exc, RuntimeError) and "could not read Username" in error_message:
-             user_facing_error = "Authentication failed. Please check your token and repository permissions."
+            user_facing_error = "Authentication failed. Please check your token and repository permissions."
         elif isinstance(exc, TimeoutError) or "timed out" in error_message:
-             user_facing_error = "Cloning timed out. The repository might be too large or the network slow."
+            user_facing_error = "Cloning timed out. The repository might be too large or the network slow."
         elif isinstance(exc, ValueError) and "Invalid repository URL" in error_message:
             user_facing_error = "Invalid repository URL format."
         elif isinstance(exc, FileNotFoundError):
@@ -149,7 +150,9 @@ async def process_query(
     return template_response(context=context)
 
 
-def _print_query(url: str, max_file_size: int, pattern_type: str, pattern: str, access_token: Optional[str] = None) -> None:
+def _print_query(
+    url: str, max_file_size: int, pattern_type: str, pattern: str, access_token: Optional[str] = None
+) -> None:
     """
     Print a formatted summary of the query details, including the URL, file size,
     and pattern information, for easier debugging or logging.
@@ -179,7 +182,9 @@ def _print_query(url: str, max_file_size: int, pattern_type: str, pattern: str,
         print(f" | {Colors.YELLOW}Token: [REDACTED]{Colors.END}", end="")
 
 
-def _print_error(url: str, e: Exception, max_file_size: int, pattern_type: str, pattern: str, access_token: Optional[str] = None) -> None:
+def _print_error(
+    url: str, e: Exception, max_file_size: int, pattern_type: str, pattern: str, access_token: Optional[str] = None
+) -> None:
     """
     Print a formatted error message including the URL, file size, pattern details, and the exception encountered,
     for debugging or logging purposes.
@@ -204,7 +209,9 @@ def _print_error(url: str, e: Exception, max_file_size: int, pattern_type: str,
     print(f" | {Colors.RED}{e}{Colors.END}")
 
 
-def _print_success(url: str, max_file_size: int, pattern_type: str, pattern: str, summary: str, access_token: Optional[str] = None) -> None:
+def _print_success(
+    url: str, max_file_size: int, pattern_type: str, pattern: str, summary: str, access_token: Optional[str] = None
+) -> None:
     """
     Print a formatted success message, including the URL, file size, pattern details, and a summary with estimated
     tokens, for debugging or logging purposes.
diff --git a/src/server/routers/dynamic.py b/src/server/routers/dynamic.py
index 948130fa..8f3bb8e6 100644
--- a/src/server/routers/dynamic.py
+++ b/src/server/routers/dynamic.py
@@ -1,8 +1,9 @@
 """This module defines the dynamic router for handling dynamic path requests."""
 
+from typing import Optional
+
 from fastapi import APIRouter, Form, Request
 from fastapi.responses import HTMLResponse
-from typing import Optional
 
 from server.query_processor import process_query
 from server.server_config import templates
@@ -71,6 +72,8 @@ async def process_catch_all(
         The type of pattern used for the query, specified by the user.
     pattern : str
         The pattern string used in the query, specified by the user.
+    access_token : Optional[str]
+        Access token for private repositories (optional).
 
     Returns
     -------
diff --git a/src/server/routers/index.py b/src/server/routers/index.py
index a94e2f85..e14855e9 100644
--- a/src/server/routers/index.py
+++ b/src/server/routers/index.py
@@ -1,8 +1,9 @@
 """This module defines the FastAPI router for the home page of the application."""
 
+from typing import Optional
+
 from fastapi import APIRouter, Form, Request
 from fastapi.responses import HTMLResponse
-from typing import Optional
 
 from server.query_processor import process_query
 from server.server_config import EXAMPLE_REPOS, templates
@@ -69,6 +70,8 @@ async def index_post(
         The type of pattern used for the query, specified by the user.
     pattern : str
         The pattern string used in the query, specified by the user.
+    access_token : Optional[str]
+        Access token for private repositories (optional).
 
     Returns
     -------
diff --git a/src/server/templates/components/git_form.jinja b/src/server/templates/components/git_form.jinja
index b1fd7c20..2329fdc1 100644
--- a/src/server/templates/components/git_form.jinja
+++ b/src/server/templates/components/git_form.jinja
@@ -97,9 +97,7 @@
                 </div>
                 <!-- GitHub Token Input -->
                 <div class="w-full sm:w-[300px] mt-3">
-                    <label for="access_token" class="block text-gray-700 mb-1">
-                        Access Token (Optional, for private repos)
-                    </label>
+                    <label for="access_token" class="block text-gray-700 mb-1">Access Token (Optional, for private repos)</label>
                     <div class="relative">
                         <div class="w-full h-full rounded bg-gray-900 translate-y-1 translate-x-1 absolute inset-0 z-10"></div>
                         <input type="password"
diff --git a/tests/test_cli.py b/tests/test_cli.py
index bd83d32b..8fcfa3a0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,38 +1,40 @@
 """Tests for the gitingest cli."""
 
-import os
-import pytest
 from unittest.mock import AsyncMock
 
+# import os # Unused import
+import pytest
 from click.testing import CliRunner
 
 from gitingest.cli import main
 from gitingest.config import MAX_FILE_SIZE, OUTPUT_FILE_NAME
 
-# Mock the ingest_async function to avoid actual processing
+
 @pytest.fixture(autouse=True)
 def mock_ingest_async(mocker):
+    """Mock the ingest_async function to avoid actual processing."""
     mock = mocker.patch("gitingest.cli.ingest_async", new_callable=AsyncMock)
     mock.return_value = ("summary", "tree", "content")
     return mock
 
 
-def test_cli_with_default_options(mock_ingest_async):
+def test_cli_with_default_options(mock_ingest_async_fixture):
+    """Test CLI runs with default options and calls ingest_async correctly."""
     runner = CliRunner()
     result = runner.invoke(main, ["./"])
     assert result.exit_code == 0
     # Use keyword args only, check source explicitly
-    call_args, call_kwargs = mock_ingest_async.await_args
+    call_args, call_kwargs = mock_ingest_async_fixture.await_args
     assert call_args == ()
-    assert call_kwargs.get("source") in ('.', './')
+    assert call_kwargs.get("source") in (".", "./")
     assert call_kwargs == {
-        "source": call_kwargs.get("source"), # Keep the actual source value
+        "source": call_kwargs.get("source"),  # Keep the actual source value
         "max_file_size": MAX_FILE_SIZE,
         "include_patterns": set(),
         "exclude_patterns": set(),
         "branch": None,
-        "output": OUTPUT_FILE_NAME, # Check default output name
-        "access_token": None, # Renamed token param
+        "output": OUTPUT_FILE_NAME,  # Check default output name
+        "access_token": None,  # Renamed token param
     }
 
     output_lines = result.output.strip().split("\n")
@@ -44,7 +46,8 @@ def test_cli_with_default_options(mock_ingest_async):
     #     os.remove(OUTPUT_FILE_NAME)
 
 
-def test_cli_with_options(mock_ingest_async):
+def test_cli_with_options(mock_ingest_async_fixture):
+    """Test CLI runs with various options and calls ingest_async correctly."""
     runner = CliRunner()
     output_file = "test_output.txt"
     exclude = "tests/"
@@ -65,23 +68,23 @@ def test_cli_with_options(mock_ingest_async):
             include,
             "--branch",
             branch,
-            "--access-token", # Renamed token param
+            "--access-token",  # Renamed token param
             token,
         ],
     )
     assert result.exit_code == 0
     # Use keyword args only, check source explicitly
-    call_args, call_kwargs = mock_ingest_async.await_args
+    call_args, call_kwargs = mock_ingest_async_fixture.await_args
     assert call_args == ()
-    assert call_kwargs.get("source") in ('.', './')
+    assert call_kwargs.get("source") in (".", "./")
     assert call_kwargs == {
-        "source": call_kwargs.get("source"), # Keep the actual source value
+        "source": call_kwargs.get("source"),  # Keep the actual source value
         "max_file_size": MAX_FILE_SIZE,
         "include_patterns": {include},
         "exclude_patterns": {exclude},
         "branch": branch,
         "output": output_file,
-        "access_token": token, # Renamed token param
+        "access_token": token,  # Renamed token param
     }
 
     output_lines = result.output.strip().split("\n")
diff --git a/tests/test_repository_clone.py b/tests/test_repository_clone.py
index dcb8c8cf..0b89a29e 100644
--- a/tests/test_repository_clone.py
+++ b/tests/test_repository_clone.py
@@ -507,7 +507,7 @@ async def test_clone_private_repo_with_invalid_token(mocker, capsys) -> None:
     # Mock run_command to simulate auth failure
     mock_exec = mocker.patch(
         "gitingest.cloning.run_command",
-        side_effect=RuntimeError(f"Command failed: git clone ... {auth_url} ...\\nError: {error_message}")
+        side_effect=RuntimeError(f"Command failed: git clone ... {auth_url} ...\\nError: {error_message}"),
     )
 
     with pytest.raises(RuntimeError, match=error_message):
@@ -555,17 +555,17 @@ async def test_clone_non_github_repo_with_token(mocker) -> None:
     """
     token = "gitlab_TokenValue12345"
     url = "https://gitlab.com/public/repo"
-    auth_url = f"https://oauth2:{token}@gitlab.com/public/repo" # GitLab format
+    auth_url = f"https://oauth2:{token}@gitlab.com/public/repo"  # GitLab format
     clone_config = CloneConfig(url=url, local_path="/tmp/gitlab-repo")
 
     mock_check = mocker.patch("gitingest.cloning.check_repo_exists")
     mock_exec = mocker.patch("gitingest.cloning.run_command", new_callable=AsyncMock)
 
-    await clone_repo(clone_config, access_token=token) # Use access_token
+    await clone_repo(clone_config, access_token=token)  # Use access_token
 
-    mock_check.assert_not_called() # check_repo_exists should be skipped for known host w/ token
+    mock_check.assert_not_called()  # check_repo_exists should be skipped for known host w/ token
     mock_exec.assert_called_once_with(
-        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path # Use GitLab auth URL
+        "git", "clone", "--single-branch", "--depth=1", auth_url, clone_config.local_path  # Use GitLab auth URL
     )
 
 
@@ -622,7 +622,7 @@ async def test_clone_unknown_host_with_token(mocker) -> None:
 
     await clone_repo(clone_config, access_token=token)
 
-    mock_check.assert_called_once_with(url) # Existence check should be called
+    mock_check.assert_called_once_with(url)  # Existence check should be called
     mock_exec.assert_called_once_with(
-        "git", "clone", "--single-branch", "--depth=1", url, clone_config.local_path # URL unchanged
+        "git", "clone", "--single-branch", "--depth=1", url, clone_config.local_path  # URL unchanged
     )

From e7c671168f07713d10c937f02d6d787aecd56594 Mon Sep 17 00:00:00 2001
From: Timo Hennig <31155789+TheRealTimo@users.noreply.github.com>
Date: Fri, 11 Apr 2025 22:54:28 +0200
Subject: [PATCH 3/4] Fix README

---
 README.md | 20 ++++++++++----------
 1 file changed, 10 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md
index 7d728edc..3d74aa39 100644
--- a/README.md
+++ b/README.md
@@ -90,13 +90,13 @@ You can provide a Personal Access Token (PAT) to clone private repositories from
 from gitingest import ingest
 
 # Public repo or local path
-summary, tree, content = ingest(\"path/to/directory\")
-summary, tree, content = ingest(\"https://github.com/cyclotruc/gitingest\")
+summary, tree, content = ingest("path/to/directory")
+summary, tree, content = ingest("https://github.com/cyclotruc/gitingest")
 
 # Private repo with token
 summary, tree, content = ingest(
-    \"https://github.com/your-user/your-private-repo\",
-    access_token=\"YOUR_TOKEN\"
+    "https://github.com/your-user/your-private-repo",
+    access_token="YOUR_TOKEN"
 )
 ```
 
@@ -108,11 +108,11 @@ from gitingest import ingest_async
 import asyncio
 
 # Public repo or local path
-result = asyncio.run(ingest_async(\"path/to/directory\"))
+result = asyncio.run(ingest_async("path/to/directory"))
 
 # Private repo with token
 summary, tree, content = asyncio.run(
-    ingest_async(\"https://gitlab.com/your-group/your-private-repo\", access_token=\"YOUR_TOKEN\")
+    ingest_async("https://gitlab.com/your-group/your-private-repo", access_token="YOUR_TOKEN")
 )
 ```
 
@@ -121,13 +121,13 @@ summary, tree, content = asyncio.run(
 ```python
 from gitingest import ingest_async
 
-# Public repo or local path
-summary, tree, content = await ingest_async(\"path/to/directory\")
+# Use await directly in Jupyter
+summary, tree, content = await ingest_async("path/to/directory")
 
 # Private repo with token (use await directly in Jupyter)
 summary, tree, content = await ingest_async(
-    \"https://github.com/your-user/your-private-repo\",
-    access_token=\"YOUR_TOKEN\"
+    "https://github.com/your-user/your-private-repo",
+    access_token="YOUR_TOKEN"
 )
 ```
 

From 96b9d46403860ca714648d512287d43097cb510e Mon Sep 17 00:00:00 2001
From: Timo Hennig <31155789+TheRealTimo@users.noreply.github.com>
Date: Wed, 16 Apr 2025 21:49:20 +0200
Subject: [PATCH 4/4] Split logic to not have to ignore pylint

---
 src/gitingest/cloning.py      | 84 ++++++++++++++++++++++-------------
 src/server/query_processor.py | 29 +++++-------
 tests/test_cli.py             | 23 +++++-----
 tests/test_cloning.py         | 65 +++++++++++++++++++++++++++
 4 files changed, 140 insertions(+), 61 deletions(-)
 create mode 100644 tests/test_cloning.py

diff --git a/src/gitingest/cloning.py b/src/gitingest/cloning.py
index c36e205e..b0854f42 100644
--- a/src/gitingest/cloning.py
+++ b/src/gitingest/cloning.py
@@ -18,13 +18,12 @@
 KNOWN_HOST_AUTH = {
     "github.com": {"method": "prefix"},
     "gitlab.com": {"method": "oauth2"},
-    "codeberg.org": {"method": "prefix"},  # Gitea instances often use prefix
-    "bitbucket.org": {"method": "prefix", "user": "x-token-auth"},  # Bitbucket uses x-token-auth:<token>
+    "codeberg.org": {"method": "prefix"},
+    "bitbucket.org": {"method": "prefix", "user": "x-token-auth"},
 }
 
 
 @async_timeout(TIMEOUT)
-# pylint: disable=too-many-branches, too-many-statements
 async def clone_repo(config: CloneConfig, access_token: Optional[str] = None) -> None:
     """
     Clone a repository to a local path based on the provided configuration.
@@ -62,35 +61,15 @@ async def clone_repo(config: CloneConfig, access_token: Optional[str] = None) ->
         raise OSError(f"Failed to create parent directory {parent_dir}: {exc}") from exc
 
     # Construct authenticated URL based on host if token is provided
-    auth_url = url
-    should_skip_check = False
-    if access_token:
-        try:
-            parsed_url = urlparse(url)
-            hostname = parsed_url.netloc.lower()
-            host_info = KNOWN_HOST_AUTH.get(hostname)
-
-            if host_info:
-                method = host_info["method"]
-                if method == "prefix":
-                    user = host_info.get("user")
-                    if user:
-                        auth_url = url.replace("https://", f"https://{user}:{access_token}@")
-                    else:
-                        auth_url = url.replace("https://", f"https://{access_token}@")
-                elif method == "oauth2":
-                    auth_url = url.replace("https://", f"https://oauth2:{access_token}@")
-                # Add other methods if needed
-
-                should_skip_check = True  # Skip check if token is provided for a known host
-
-        except Exception as e:
-            # Ignore parsing errors, proceed with original URL
-            print(f"Warning: Could not parse URL '{url}' for token auth: {e}")
+    auth_url = build_auth_url(url, access_token)
 
     # Skip existence check only if token is provided for a known host type
-    if not should_skip_check:
-        if not await check_repo_exists(url):
+    parsed = urlparse(url)
+    host = parsed.netloc.lower()
+    is_known_git_host = host in KNOWN_HOST_AUTH
+    if not (access_token and is_known_git_host):
+        exists = await check_repo_exists(url)
+        if not exists:
             raise ValueError("Repository not found or inaccessible. If private, provide a token.")
 
     clone_cmd = ["git", "clone", "--single-branch"]
@@ -126,3 +105,48 @@ async def clone_repo(config: CloneConfig, access_token: Optional[str] = None) ->
 
         # Check out the specific commit and/or subpath
         await run_command(*checkout_cmd)
+
+
+def build_auth_url(url: str, access_token: Optional[str] = None) -> str:
+    """
+    Build an authenticated URL for cloning a repository.
+
+    Parameters
+    ----------
+    url : str
+        The original repository URL.
+    access_token : str, optional
+        Access token for private repositories (optional).
+
+    Returns
+    -------
+    str
+        The authenticated URL.
+    """
+    parsed = urlparse(url)
+    final_url = url
+
+    # Return the original URL if no access token is provided
+    if access_token:
+        if not parsed.scheme or not parsed.netloc:
+            print(f"Warning: Could not parse URL '{url}' for token auth: invalid URL")
+
+        host = parsed.netloc.lower()
+        host_info = KNOWN_HOST_AUTH.get(host)
+        if host_info:
+            method = host_info["method"]
+            if method == "prefix":
+                user = host_info.get("user")
+                if user:
+                    # e.g. bitbucket.org → x-token-auth:<token>@bitbucket.org/…
+                    final_url = url.replace("https://", f"https://{user}:{access_token}@", 1)
+                else:
+                    # e.g. github.com, codeberg.org → <token>@host/…
+                    final_url = url.replace("https://", f"https://{access_token}@", 1)
+
+            elif method == "oauth2":
+                # gitlab.com → oauth2:<token>@gitlab.com/…
+                final_url = url.replace("https://", f"https://oauth2:{access_token}@", 1)
+
+            # fall‑through: shouldn't happen if KNOWN_HOST_AUTH is correct
+    return final_url
diff --git a/src/server/query_processor.py b/src/server/query_processor.py
index 0ec8ee45..cdeb9f48 100644
--- a/src/server/query_processor.py
+++ b/src/server/query_processor.py
@@ -13,7 +13,6 @@
 from server.server_utils import Colors, log_slider_to_size
 
 
-# pylint: disable=too-many-branches
 async def process_query(
     request: Request,
     input_text: str,
@@ -102,24 +101,16 @@ async def process_query(
             print(f"{Colors.BROWN}WARN{Colors.END}: {Colors.RED}<-  {Colors.END}", end="")
             print(f"{Colors.RED}{exc}{Colors.END}")
 
-        error_message = str(exc)
-        user_facing_error = f"An unexpected error occurred: {error_message}"  # Default
-
-        # Check for specific, common errors to provide better messages
-        if isinstance(exc, ValueError) and "Repository not found or inaccessible" in error_message:
-            user_facing_error = error_message  # Use the specific message from cloning.py
-        elif isinstance(exc, RuntimeError) and "Authentication failed" in error_message:
-            user_facing_error = "Authentication failed. Please check your token and repository permissions."
-        elif isinstance(exc, RuntimeError) and "could not read Username" in error_message:
-            user_facing_error = "Authentication failed. Please check your token and repository permissions."
-        elif isinstance(exc, TimeoutError) or "timed out" in error_message:
-            user_facing_error = "Cloning timed out. The repository might be too large or the network slow."
-        elif isinstance(exc, ValueError) and "Invalid repository URL" in error_message:
-            user_facing_error = "Invalid repository URL format."
-        elif isinstance(exc, FileNotFoundError):
-            user_facing_error = "Ingestion failed: Cloned repository directory not found."
-
-        context["error_message"] = user_facing_error
+            context["error_message"] = f"Error: {exc}"
+        if "405" in str(exc):
+            context["error_message"] = (
+                "Repository not found. Please make sure it is public (private repositories will be supported soon)"
+            )
+        if "404" in str(exc):
+            context["error_message"] = (
+                "Repository not found. Ensure it exists, is public or you have correct access token."
+            )
+
         return template_response(context=context)
 
     if len(content) > MAX_DISPLAY_SIZE:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 8fcfa3a0..11ce8659 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,5 +1,6 @@
 """Tests for the gitingest cli."""
 
+import os
 from unittest.mock import AsyncMock
 
 # import os # Unused import
@@ -10,21 +11,21 @@
 from gitingest.config import MAX_FILE_SIZE, OUTPUT_FILE_NAME
 
 
-@pytest.fixture(autouse=True)
-def mock_ingest_async(mocker):
+@pytest.fixture(autouse=True, name="ingest_async_mock")
+def _ingest_async_mock(mocker):
     """Mock the ingest_async function to avoid actual processing."""
     mock = mocker.patch("gitingest.cli.ingest_async", new_callable=AsyncMock)
     mock.return_value = ("summary", "tree", "content")
     return mock
 
 
-def test_cli_with_default_options(mock_ingest_async_fixture):
+def test_cli_with_default_options(ingest_async_mock):
     """Test CLI runs with default options and calls ingest_async correctly."""
     runner = CliRunner()
     result = runner.invoke(main, ["./"])
     assert result.exit_code == 0
     # Use keyword args only, check source explicitly
-    call_args, call_kwargs = mock_ingest_async_fixture.await_args
+    call_args, call_kwargs = ingest_async_mock.await_args
     assert call_args == ()
     assert call_kwargs.get("source") in (".", "./")
     assert call_kwargs == {
@@ -39,14 +40,13 @@ def test_cli_with_default_options(mock_ingest_async_fixture):
 
     output_lines = result.output.strip().split("\n")
     assert f"Analysis complete! Output written to: {OUTPUT_FILE_NAME}" in output_lines
-    # assert os.path.exists(OUTPUT_FILE_NAME), f"Output file was not created at {OUTPUT_FILE_NAME}" # Remove check
 
     # Clean up: Remove the default output file if created by the mocked logic (or original if mock fails)
-    # if os.path.exists(OUTPUT_FILE_NAME): # Remove cleanup
-    #     os.remove(OUTPUT_FILE_NAME)
+    if os.path.exists(OUTPUT_FILE_NAME):  # Remove cleanup
+        os.remove(OUTPUT_FILE_NAME)
 
 
-def test_cli_with_options(mock_ingest_async_fixture):
+def test_cli_with_options(ingest_async_mock):
     """Test CLI runs with various options and calls ingest_async correctly."""
     runner = CliRunner()
     output_file = "test_output.txt"
@@ -74,7 +74,7 @@ def test_cli_with_options(mock_ingest_async_fixture):
     )
     assert result.exit_code == 0
     # Use keyword args only, check source explicitly
-    call_args, call_kwargs = mock_ingest_async_fixture.await_args
+    call_args, call_kwargs = ingest_async_mock.await_args
     assert call_args == ()
     assert call_kwargs.get("source") in (".", "./")
     assert call_kwargs == {
@@ -89,8 +89,7 @@ def test_cli_with_options(mock_ingest_async_fixture):
 
     output_lines = result.output.strip().split("\n")
     assert f"Analysis complete! Output written to: {output_file}" in output_lines
-    # assert os.path.exists(output_file), f"Output file was not created at {output_file}" # Remove check
 
     # Clean up
-    # if os.path.exists(output_file): # Remove cleanup
-    #     os.remove(output_file)
+    if os.path.exists(output_file):  # Remove cleanup
+        os.remove(output_file)
diff --git a/tests/test_cloning.py b/tests/test_cloning.py
new file mode 100644
index 00000000..12aaa310
--- /dev/null
+++ b/tests/test_cloning.py
@@ -0,0 +1,65 @@
+"""Tests for the cloning module functionality."""
+
+from gitingest.cloning import build_auth_url
+
+
+class TestBuildAuthUrl:
+    """Test cases for the build_auth_url function."""
+
+    def test_no_token_returns_original_url(self):
+        """Test that when no token is provided, the original URL is returned."""
+        url = "https://github.com/owner/repo.git"
+        assert build_auth_url(url) == url
+        assert build_auth_url(url, None) == url
+
+    def test_github_auth_url(self):
+        """Test GitHub URL authentication with prefix method."""
+        url = "https://github.com/owner/repo.git"
+        token = "ghp_1234abcd"
+        expected = "https://ghp_1234abcd@github.com/owner/repo.git"
+        assert build_auth_url(url, token) == expected
+
+    def test_gitlab_auth_url(self):
+        """Test GitLab URL authentication with oauth2 method."""
+        url = "https://gitlab.com/owner/repo.git"
+        token = "glpat-1234abcd"
+        expected = "https://oauth2:glpat-1234abcd@gitlab.com/owner/repo.git"
+        assert build_auth_url(url, token) == expected
+
+    def test_bitbucket_auth_url(self):
+        """Test Bitbucket URL authentication with prefix method and custom user."""
+        url = "https://bitbucket.org/owner/repo.git"
+        token = "1234abcd"
+        expected = "https://x-token-auth:1234abcd@bitbucket.org/owner/repo.git"
+        assert build_auth_url(url, token) == expected
+
+    def test_codeberg_auth_url(self):
+        """Test Codeberg URL authentication with prefix method."""
+        url = "https://codeberg.org/owner/repo.git"
+        token = "1234abcd"
+        expected = "https://1234abcd@codeberg.org/owner/repo.git"
+        assert build_auth_url(url, token) == expected
+
+    def test_unknown_host(self):
+        """Test that unknown hosts return the original URL."""
+        url = "https://unknown-git-host.com/owner/repo.git"
+        token = "1234abcd"
+        assert build_auth_url(url, token) == url
+
+    def test_case_insensitive_hostname(self):
+        """Test that hostname matching is case-insensitive."""
+        url = "https://GitHub.com/owner/repo.git"
+        token = "1234abcd"
+        expected = "https://1234abcd@GitHub.com/owner/repo.git"
+        assert build_auth_url(url, token) == expected
+
+    def test_invalid_url(self, capfd):
+        """Test handling of invalid URLs."""
+        url = "not-a-valid-url"
+        token = "1234abcd"
+        result = build_auth_url(url, token)
+
+        # Check that a warning was printed
+        out, _ = capfd.readouterr()
+        assert "Warning: Could not parse URL" in out
+        assert result == url
