Merge main into fix/windows-support

This commit is contained in:
Vitalii Barenin 2025-07-06 15:55:23 +03:00
parent 56ce0ed19e
commit 9dfb279ae3
No known key found for this signature in database
GPG key ID: 9458A9ACC018AD28
28 changed files with 305 additions and 2319 deletions

View file

@ -10,16 +10,6 @@ updates:
dependency-type: "development"
update-types: ["minor", "patch"]
# ─── Node (npm) ───────────────────────────────
- package-ecosystem: "npm"
directory: "/"
schedule: { interval: "weekly" }
labels: [ "dependencies", "npm" ]
cooldown: # wait before opening PRs
semver-major-days: 30
semver-minor-days: 7
semver-patch-days: 3
# ─── GitHub Actions ───────────────────────────
- package-ecosystem: "github-actions"
directory: "/"

View file

@ -48,27 +48,3 @@ jobs:
- name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1
if: ${{ matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' }}
frontend:
needs: test # Builds Tailwind CSS only if tests pass
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install Node deps
run: npm ci
- name: Build CSS
run: npm run build:css # Creates src/static/css/site.css
- name: Upload artefact
uses: actions/upload-artifact@v4
with:
name: static-css
path: src/static/css/site.css

View file

@ -1,88 +0,0 @@
name: Publish to PyPI
on:
release:
types: [created] # Run when you click “Publish release”
workflow_dispatch: # ... or run it manually from the Actions tab
permissions:
contents: read
# ── Build the Tailwind CSS bundle ───────────────────────────────
jobs:
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- name: Install deps + build Tailwind
run: |
npm ci
npm run build:css
- name: Upload built CSS
uses: actions/upload-artifact@v4
with:
name: frontend-assets
path: src/static/css/site.css
if-no-files-found: error
# ── Build wheel/sdist (needs CSS) and upload “dist/” ────────────
release-build:
needs: frontend-build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Grab site.css produced above
- uses: actions/download-artifact@v4
with:
name: frontend-assets
path: src/static/css/
- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install build backend
run: |
python -m pip install --upgrade pip
python -m pip install build twine
python -m build
twine check dist/*
- name: Upload dist artefact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# ── Publish to PyPI (only if “dist/” succeeded) ─────────────────
pypi-publish:
needs: release-build
runs-on: ubuntu-latest
environment: pypi # Creates the “pypi” environment in repo-settings
permissions:
id-token: write # OIDC token for trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
verbose: true

View file

@ -6,9 +6,9 @@ on:
push:
branches: [ main ]
permissions: read-all # Default for the whole workflow
permissions: read-all
concurrency: # (optional) avoid overlapping runs
concurrency: # avoid overlapping runs
group: scorecard-${{ github.ref }}
cancel-in-progress: true
@ -27,7 +27,7 @@ jobs:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde
with:
results_file: results.sarif
results_format: sarif

213
.gitignore vendored
View file

@ -1,185 +1,40 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
tmp/*
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
.venv*
env/
venv/
ENV/
env.bak/
venv.bak/
.python-version
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.vscode/settings.json
# Operating-system
.DS_Store
Thumbs.db
# JavaScript tooling
node_modules/
# Editor / IDE settings
.vscode/
!.vscode/launch.json
.idea/
*.swp
# CSS
src/static/css/site.css
# Python virtual-envs & tooling
.venv*/
.python-version
__pycache__/
*.egg-info/
*.egg
.ruff_cache/
# Project specific
# Test artifacts & coverage
.pytest_cache/
.coverage
coverage.xml
htmlcov/
# Build, distribution & docs
build/
dist/
*.wheel
# Logs & runtime output
*.log
logs/
*.tmp
tmp/
# Project-specific files
history.txt
cleanup.py
Caddyfile
# ignore default output directory
tmp/*
# Gitingest
digest.txt

View file

@ -151,4 +151,4 @@ repos:
- repo: meta
hooks:
- id: check-hooks-apply
- id: check-useless-excludes
- id: check-useless-excludes

View file

@ -17,7 +17,6 @@ If you ever get stuck, reach out on [Discord](https://discord.com/invite/zerRaGK
## How to submit a Pull Request
> **Prerequisites**: The project uses **Python 3.9+** and `pre-commit` for development.
> If you plan to touch the frontend, you'll also need **Node ≥18** (for Tailwind).
1. **Fork** the repository.
@ -63,22 +62,7 @@ If you ever get stuck, reach out on [Discord](https://discord.com/invite/zerRaGK
pre-commit run --all-files
```
9. **If you edited templates or CSS** rebuild Tailwind:
```bash
# one-time install
npm ci
# build once
npm run build:css
# or watch & rebuild on every save
npm run dev:css
```
*Skip this step if your PR only touches Python code.*
10. **Run the local server** to sanity-check:
9. **Run the local server** to sanity-check:
```bash
cd src
@ -87,30 +71,22 @@ If you ever get stuck, reach out on [Discord](https://discord.com/invite/zerRaGK
Open [http://localhost:8000](http://localhost:8000) to confirm everything works.
11. **Commit** (signed):
10. **Commit** (signed):
```bash
git commit -S -m "Your commit message"
```
If *pre-commit* complains, fix the problems and repeat **6 10**.
If *pre-commit* complains, fix the problems and repeat **5 9**.
12. **Push** your branch:
11. **Push** your branch:
```bash
git push origin your-branch
```
13. **Open a pull request** on GitHub with a clear description.
12. **Open a pull request** on GitHub with a clear description.
14. **Iterate** on any review feedback—update your branch and repeat **6 13** as needed.
13. **Iterate** on any review feedback—update your branch and repeat **6 11** as needed.
*(Optional) Invite a maintainer to your branch for easier collaboration.*
---
## CSS & build artefacts
- **Do not commit `src/static/css/site.css`.** The CI pipeline runs `npm run build:css` during the container/image build, so the artefact is produced automatically.
- When developing locally you may run the build yourself (see step 9) so you can preview the styles.

View file

@ -1,60 +1,41 @@
# ---------- Stage 1: Build CSS with Node -------------------------
FROM node:20-alpine AS css-builder
WORKDIR /frontend
# Copy only files that affect the CSS build to leverage Docker cache
COPY package*.json ./
RUN npm ci
# Tailwind source --> final CSS
# (adjust the paths if you store Tailwind input elsewhere)
COPY tailwind.config.js ./ # Tailwind config
COPY src/static/css/ ./src/static/css/ # Tailwind input file(s)
RUN npm run build:css # writes ./src/static/css/site.css
# ---------- Stage 2: Install Python dependencies -----------------
FROM python:3.12-slim AS python-builder
# Stage 1: Install Python dependencies
FROM python:3.13-slim AS python-builder
WORKDIR /build
# System build tools first (so later layers are cached if unchanged)
# System build tools
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Python dependencies
# Metadata and code that setuptools needs
COPY pyproject.toml .
COPY src/ ./src/
# Install runtime dependencies defined in pyproject.toml
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir --timeout 1000 "."
&& pip install --no-cache-dir --timeout 1000 .
# ---------- Stage 3: Final runtime image -------------------------
FROM python:3.12-slim
# Stage 2: Runtime image
FROM python:3.13-slim
LABEL org.opencontainers.image.source="https://github.com/cyclotruc/gitingest"
# Minimal runtime utilities
RUN apt-get update \
&& apt-get install -y --no-install-recommends git curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Create non-root user (uid 1000 == common default on Linux host)
RUN useradd -m -u 1000 appuser
# ── Copy Python site-packages & app code ───────────────────────────
COPY --from=python-builder /usr/local/lib/python3.12/site-packages/ \
/usr/local/lib/python3.12/site-packages/
# Copy Python site-packages and code
COPY --from=python-builder /usr/local/lib/python3.13/site-packages/ \
/usr/local/lib/python3.13/site-packages/
COPY src/ ./
# ── Copy the freshly-built CSS ────────────────────────────────────
COPY --from=css-builder /frontend/src/static/css/site.css \
src/static/css/site.css
# Fix permissions
# Set permissions
RUN chown -R appuser:appuser /app
USER appuser

1691
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
{
"private": true,
"name": "gitingest-frontend",
"version": "0.0.0",
"scripts": {
"build:css": "tailwindcss -i ./src/static/css/tailwind.css -o ./src/static/css/site.css --minify",
"dev:css": "tailwindcss -i ./src/static/css/tailwind.css -o ./src/static/css/site.css --watch"
},
"devDependencies": {
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"simple-icons": "^15.4.0",
"tailwindcss": "^3.4.17"
}
}

View file

@ -58,7 +58,6 @@ build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = {find = {where = ["src"]}}
include-package-data = true
package-data = {"gitingest" = ["static/css/*.css"]}
# Linting configuration
[tool.pylint.format]

View file

@ -27,6 +27,7 @@ async def ingest_async(
branch: str | None = None,
tag: str | None = None,
include_gitignored: bool = False,
include_submodules: bool = False,
token: str | None = None,
output: str | None = None,
) -> tuple[str, str, str]:
@ -52,6 +53,8 @@ async def ingest_async(
The tag to clone and ingest. If ``None``, no tag is used.
include_gitignored : bool
If ``True``, include files ignored by ``.gitignore`` and ``.gitingestignore`` (default: ``False``).
include_submodules : bool
If ``True``, recursively include all Git submodules within the repository (default: ``False``).
token : str | None
GitHub personal access token (PAT) for accessing private repositories.
Can also be set via the ``GITHUB_TOKEN`` environment variable.
@ -86,6 +89,8 @@ async def ingest_async(
if query.url:
_override_branch_and_tag(query, branch=branch, tag=tag)
query.include_submodules = include_submodules
async with _clone_repo_if_remote(query, token=token):
summary, tree, content = ingest_query(query)
await _write_output(tree, content=content, target=output)
@ -101,6 +106,7 @@ def ingest(
branch: str | None = None,
tag: str | None = None,
include_gitignored: bool = False,
include_submodules: bool = False,
token: str | None = None,
output: str | None = None,
) -> tuple[str, str, str]:
@ -126,6 +132,8 @@ def ingest(
The tag to clone and ingest. If ``None``, no tag is used.
include_gitignored : bool
If ``True``, include files ignored by ``.gitignore`` and ``.gitingestignore`` (default: ``False``).
include_submodules : bool
If ``True``, recursively include all Git submodules within the repository (default: ``False``).
token : str | None
GitHub personal access token (PAT) for accessing private repositories.
Can also be set via the ``GITHUB_TOKEN`` environment variable.
@ -156,6 +164,7 @@ def ingest(
branch=branch,
tag=tag,
include_gitignored=include_gitignored,
include_submodules=include_submodules,
token=token,
output=output,
),

View file

@ -4,20 +4,13 @@ from __future__ import annotations
import asyncio
import base64
import os
import re
import sys
from typing import Final
from urllib.parse import urlparse
from starlette.status import (
HTTP_200_OK,
HTTP_301_MOVED_PERMANENTLY,
HTTP_302_FOUND,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
import httpx
from starlette.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
from gitingest.utils.compat_func import removesuffix
from gitingest.utils.exceptions import InvalidGitHubTokenError
@ -133,45 +126,28 @@ async def check_repo_exists(url: str, token: str | None = None) -> bool:
If the host returns an unrecognised status code.
"""
# TODO: use `requests` instead of `curl`
cmd: list[str] = [
"curl",
"--silent", # Suppress output
"--location", # Follow redirects
"--write-out",
"%{http_code}", # Write the HTTP status code to stdout
"-o",
os.devnull,
]
headers = {}
if token and is_github_host(url):
host, owner, repo = _parse_github_url(url)
# Public GitHub vs. GitHub Enterprise
base_api = "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
url = f"{base_api}/repos/{owner}/{repo}"
cmd += ["--header", f"Authorization: Bearer {token}"]
headers["Authorization"] = f"Bearer {token}"
cmd.append(url)
async with httpx.AsyncClient(follow_redirects=True) as client:
try:
response = await client.head(url, headers=headers)
except httpx.RequestError:
return False
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
status_code = response.status_code
if proc.returncode != 0:
return False
status = int(stdout.decode().strip())
if status in {HTTP_200_OK, HTTP_301_MOVED_PERMANENTLY}:
if status_code == HTTP_200_OK:
return True
# TODO: handle 302 redirects
if status in {HTTP_404_NOT_FOUND, HTTP_302_FOUND}:
if status_code in {HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND}:
return False
if status in {HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN}:
return False
msg = f"Unexpected HTTP status {status} for {url}"
msg = f"Unexpected HTTP status {status_code} for {url}"
raise RuntimeError(msg)

View file

@ -10,10 +10,7 @@ from gitingest.ingestion import ingest_query
from gitingest.query_parser import IngestionQuery, parse_query
from gitingest.utils.git_utils import validate_github_token
from server.models import IngestErrorResponse, IngestResponse, IngestSuccessResponse
from server.server_config import (
DEFAULT_MAX_FILE_SIZE_KB,
MAX_DISPLAY_SIZE,
)
from server.server_config import MAX_DISPLAY_SIZE
from server.server_utils import Colors, log_slider_to_size
@ -148,10 +145,11 @@ def _print_query(url: str, max_file_size: int, pattern_type: str, pattern: str)
The actual pattern string to include or exclude in the query.
"""
default_max_file_kb = 50
print(f"{Colors.WHITE}{url:<20}{Colors.END}", end="")
if int(max_file_size / 1024) != DEFAULT_MAX_FILE_SIZE_KB:
if int(max_file_size / 1024) != default_max_file_kb:
print(
f" | {Colors.YELLOW}Size: {int(max_file_size / 1024)}kb{Colors.END}",
f" | {Colors.YELLOW}Size: {int(max_file_size / 1024)}kB{Colors.END}",
end="",
)
if pattern_type == "include" and pattern != "":

View file

@ -50,9 +50,8 @@
{% endif %}
{% endblock %}
</title>
{# Style sheets #}
<link rel="preload" href="/static/css/site.css" as="style">
{% block css %}<link rel="stylesheet" href="/static/css/site.css">{% endblock %}
<script src="https://cdn.tailwindcss.com"></script>
{% include 'components/tailwind_components.html' %}
</head>
<body class="bg-[#FFFDF8] min-h-screen flex flex-col">
{% include 'components/navbar.jinja' %}

View file

@ -1,5 +1,5 @@
{# Icon link #}
{% macro icon_link(href, icon, label) -%}
{% macro footer_icon_link(href, icon, label) -%}
<a href="{{ href }}"
target="_blank"
rel="noopener noreferrer"

View file

@ -1,19 +1,19 @@
{% from 'components/_macros.jinja' import icon_link %}
{% from 'components/_macros.jinja' import footer_icon_link %}
<footer class="w-full border-t-[3px] border-gray-900 mt-auto">
<div class="max-w-4xl mx-auto px-4 py-4">
<div class="grid grid-cols-2 items-center text-gray-900 text-sm">
{# Left column — Chrome + PyPI #}
<div class="flex items-center space-x-4">
{{ icon_link('https://chromewebstore.google.com/detail/adfjahbijlkjfoicpjkhjicpjpjfaood',
{{ footer_icon_link('https://chromewebstore.google.com/detail/adfjahbijlkjfoicpjkhjicpjpjfaood',
'icons/chrome.svg',
'Chrome Extension') }}
{{ icon_link('https://pypi.org/project/gitingest',
{{ footer_icon_link('https://pypi.org/project/gitingest',
'icons/python.svg',
'Python Package') }}
</div>
{# Right column - Discord #}
<div class="flex justify-end">
{{ icon_link('https://discord.gg/zerRaGK9EC',
{{ footer_icon_link('https://discord.gg/zerRaGK9EC',
'icons/discord.svg',
'Discord') }}
</div>

View file

@ -16,7 +16,7 @@
placeholder="https://github.com/..."
value="{{ repo_url if repo_url else '' }}"
required
class="border-[3px] w-full relative z-20 border-gray-900 placeholder-gray-600 text-lg font-medium focus:outline-none py-3.5 px-6 rounded">
class="border-[3px] w-full relative z-20 border-gray-900 placeholder-gray-600 text-lg font-medium focus:outline-none py-3.5 px-6 rounded bg-[#E8F0FE]">
</div>
<!-- Ingest button -->
<div class="relative w-auto flex-shrink-0 h-full group">
@ -40,9 +40,9 @@
<!-- Pattern type selector -->
<div class="relative flex items-center">
<select id="pattern_type"
onchange="changePattern()"
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 cursor-pointer">
onchange="changePattern()"
class="pattern-select">
<option value="exclude"
{% if pattern_type == 'exclude' or not pattern_type %}selected{% endif %}>
Exclude

View file

@ -1,7 +1,7 @@
<header class="sticky top-0 bg-[#FFFDF8] border-b-[3px] border-gray-900 z-50">
<div class="max-w-4xl mx-auto px-4">
<div class="flex justify-between items-center h-16">
<!-- Logo -->
{# Logo #}
<div class="flex items-center gap-4">
<h1 class="text-2xl font-bold tracking-tight">
<a href="/" class="hover:opacity-80 transition-opacity">
@ -9,33 +9,26 @@
</a>
</h1>
</div>
<!-- Navigation with updated styling -->
{# Navigation with updated styling #}
<nav class="flex items-center space-x-6">
<a href="/llm.txt"
class="text-gray-900 hover:-translate-y-0.5 transition-transform flex items-center">
<a href="/llm.txt" class="link-bounce flex items-center text-gray-900">
<span class="badge-new">NEW</span>
/llm.txt
</a>
{# GitHub link #}
<div class="flex items-center gap-2">
<a href="https://github.com/cyclotruc/gitingest"
target="_blank"
rel="noopener noreferrer"
class="text-gray-900 hover:-translate-y-0.5 transition-transform flex items-center gap-1.5">
<svg class="w-4 h-4"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true">
<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd">
</path>
</svg>
class="link-bounce flex items-center gap-1.5 text-gray-900">
<img src="/static/icons/github.svg" class="w-4 h-4" alt="GitHub logo">
GitHub
</a>
<div class="flex items-center text-sm text-gray-600">
<svg class="w-4 h-4 text-[#ffc480] mr-1"
fill="currentColor"
viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{# Star counter #}
<div class="no-drag flex items-center text-sm text-gray-600">
<img src="/static/svg/github-star.svg"
class="w-4 h-4 mr-1"
alt="GitHub star icon">
<span id="github-stars">0</span>
</div>
</div>

View file

@ -1,46 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.badge-new {
@apply inline-block -rotate-6 -translate-y-1 mx-1 px-1
bg-[#FE4A60] border border-gray-900 text-white
text-[10px] font-bold shadow-[2px_2px_0_0_rgba(0,0,0,1)];
}
.landing-page-title {
@apply inline-block w-full relative
text-center
text-4xl sm:text-5xl md:text-6xl lg:text-7xl
sm:pt-20 lg:pt-5
font-bold tracking-tighter;
}
.intro-text {
@apply text-center
text-gray-600 text-lg max-w-2xl mx-auto;
}
.sparkle-red {
@apply absolute flex-shrink-0 h-auto
w-14 sm:w-20 md:w-24 p-2
left-0 lg:ml-32
-translate-x-2 md:translate-x-10 lg:-translate-x-full
-translate-y-4 sm:-translate-y-8 md:-translate-y-0 lg:-translate-y-10;
}
.sparkle-green {
@apply absolute flex-shrink-0
right-0 bottom-0
w-10 sm:w-16 lg:w-20
-translate-x-10 lg:-translate-x-12
translate-y-4 sm:translate-y-10 md:translate-y-2 lg:translate-y-4;
}
.no-drag {
@apply pointer-events-none select-none;
-webkit-user-drag: none;
}
}

View file

@ -1,27 +1,29 @@
function waitForStars() {
return new Promise((resolve) => {
const check = () => {
const stars = document.getElementById("github-stars");
if (stars && stars.textContent !== "0") resolve();
else setTimeout(check, 10);
const stars = document.getElementById('github-stars');
if (stars && stars.textContent !== '0') {resolve();}
else {setTimeout(check, 10);}
};
check();
});
}
document.addEventListener("DOMContentLoaded", () => {
const urlInput = document.getElementById("input_text");
const form = document.getElementById("ingestForm");
document.addEventListener('DOMContentLoaded', () => {
const urlInput = document.getElementById('input_text');
const form = document.getElementById('ingestForm');
if (urlInput && urlInput.value.trim() && form) {
// Wait for stars to be loaded before submitting
// Wait for stars to be loaded before submitting
waitForStars().then(() => {
const submitEvent = new SubmitEvent("submit", {
const submitEvent = new SubmitEvent('submit', {
cancelable: true,
bubbles: true
});
Object.defineProperty(submitEvent, "target", {
Object.defineProperty(submitEvent, 'target', {
value: form,
enumerable: true
});

View file

@ -1,44 +1,45 @@
// Strike-through / un-strike file lines when the pattern-type menu flips.
function changePattern() {
const files = document.getElementsByName("tree-line");
const files = document.getElementsByName('tree-line');
files.forEach((el) => {
if (el.textContent.includes("Directory structure:")) return;
if (el.textContent.includes('Directory structure:')) {return;}
[
"line-through",
"text-gray-500",
"hover:text-inherit",
"hover:no-underline",
"hover:line-through",
"hover:text-gray-500",
'line-through',
'text-gray-500',
'hover:text-inherit',
'hover:no-underline',
'hover:line-through',
'hover:text-gray-500',
].forEach((cls) => el.classList.toggle(cls));
});
}
// Show/hide the Personal-Access-Token section when the “Private repository” checkbox is toggled.
function toggleAccessSettings() {
const container = document.getElementById("accessSettingsContainer");
const examples = document.getElementById("exampleRepositories");
const show = document.getElementById('showAccessSettings')?.checked;
container?.classList.toggle("hidden", !show);
examples?.classList.toggle("lg:mt-0", show);
const container = document.getElementById('accessSettingsContainer');
const examples = document.getElementById('exampleRepositories');
const show = document.getElementById('showAccessSettings')?.checked;
container?.classList.toggle('hidden', !show);
examples?.classList.toggle('lg:mt-0', show);
}
document.addEventListener("DOMContentLoaded", () => {
document.addEventListener('DOMContentLoaded', () => {
document
.getElementById("pattern_type")
?.addEventListener("change", () => changePattern());
.getElementById('pattern_type')
?.addEventListener('change', () => changePattern());
document
.getElementById("showAccessSettings")
?.addEventListener("change", toggleAccessSettings);
.getElementById('showAccessSettings')
?.addEventListener('change', toggleAccessSettings);
/* 3. Initial UI sync -------------------------------- */
// Initial UI sync
toggleAccessSettings();
changePattern();
});
});
// Make them available to existing inline attributes

View file

@ -1,5 +1,6 @@
function submitExample(repoName) {
const input = document.getElementById("input_text");
const input = document.getElementById('input_text');
if (input) {
input.value = repoName;
input.focus();

View file

@ -1,22 +1,26 @@
// Fetch GitHub stars
function formatStarCount(count) {
if (count >= 1000) return (count / 1000).toFixed(1) + 'k';
return count.toString();
}
if (count >= 1000) {return `${ (count / 1000).toFixed(1) }k`;}
async function fetchGitHubStars() {
return count.toString();
}
async function fetchGitHubStars() {
try {
const res = await fetch('https://api.github.com/repos/cyclotruc/gitingest');
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = await res.json();
document.getElementById('github-stars').textContent =
const res = await fetch('https://api.github.com/repos/cyclotruc/gitingest');
if (!res.ok) {throw new Error(`${res.status} ${res.statusText}`);}
const data = await res.json();
document.getElementById('github-stars').textContent =
formatStarCount(data.stargazers_count);
} catch (err) {
console.error('Error fetching GitHub stars:', err);
const el = document.getElementById('github-stars').parentElement;
if (el) el.style.display = 'none';
}
}
console.error('Error fetching GitHub stars:', err);
const el = document.getElementById('github-stars').parentElement;
// auto-run when script loads
fetchGitHubStars();
if (el) {el.style.display = 'none';}
}
}
// auto-run when script loads
fetchGitHubStars();

View file

@ -1,12 +1,13 @@
/* eslint-disable */
!function (t, e) {
var o, n, p, r;
if (e.__SV) return; // already loaded
let o, n, p, r;
if (e.__SV) {return;} // already loaded
window.posthog = e;
e._i = [];
e.init = function (i, s, a) {
function g(t, e) {
var o = e.split(".");
const o = e.split(".");
if (o.length === 2) {
t = t[o[0]];
e = o[1];
@ -20,12 +21,12 @@
p.type = "text/javascript";
p.crossOrigin = "anonymous";
p.async = true;
p.src = s.api_host.replace(".i.posthog.com", "-assets.i.posthog.com") + "/static/array.js";
p.src = `${ s.api_host.replace(".i.posthog.com", "-assets.i.posthog.com") }/static/array.js`;
r = t.getElementsByTagName("script")[0];
r.parentNode.insertBefore(p, r);
var u = e;
let u = e;
if (a !== undefined) {
u = e[a] = [];
} else {
@ -34,13 +35,13 @@
u.people = u.people || [];
u.toString = function (t) {
var e = "posthog";
if (a !== "posthog") e += "." + a;
if (!t) e += " (stub)";
let e = "posthog";
if (a !== "posthog") {e += `.${ a }`;}
if (!t) {e += " (stub)";}
return e;
};
u.people.toString = function () {
return u.toString(1) + ".people (stub)";
return `${ u.toString(1) }.people (stub)`;
};
@ -58,9 +59,9 @@
"createPersonProfile", "opt_in_capturing", "opt_out_capturing",
"has_opted_in_capturing", "has_opted_out_capturing", "clear_opt_in_out_capturing",
"debug", "getPageViewId"
];
];
for (n = 0; n < o.length; n++) g(u, o[n]);
for (n = 0; n < o.length; n++) {g(u, o[n]);}
e._i.push([i, s, a]);
};

View file

@ -3,19 +3,22 @@ function copyText(className) {
let textToCopy;
if (className === 'directory-structure') {
// For directory structure, get the hidden input value
// For directory structure, get the hidden input value
const hiddenInput = document.getElementById('directory-structure-content');
if (!hiddenInput) return;
if (!hiddenInput) {return;}
textToCopy = hiddenInput.value;
} else {
// For other elements, get the textarea value
const textarea = document.querySelector('.' + className);
if (!textarea) return;
// For other elements, get the textarea value
const textarea = document.querySelector(`.${ className }`);
if (!textarea) {return;}
textToCopy = textarea.value;
}
const button = document.querySelector(`button[onclick="copyText('${className}')"]`);
if (!button) return;
if (!button) {return;}
// Copy text
navigator.clipboard.writeText(textToCopy)
@ -31,9 +34,10 @@ function copyText(className) {
button.innerHTML = originalContent;
}, 1000);
})
.catch(err => {
// Show error in button
.catch((err) => {
console.error('Failed to copy text:', err);
const originalContent = button.innerHTML;
button.innerHTML = 'Failed to copy';
setTimeout(() => {
button.innerHTML = originalContent;
@ -41,17 +45,74 @@ function copyText(className) {
});
}
function getFileName(element) {
const indentSize = 4;
let path = '';
let prevIndentLevel = null;
while (element) {
const line = element.textContent;
const index = line.search(/[a-zA-Z0-9_.-]/);
const indentLevel = index / indentSize;
// Stop when we reach or go above the top-level directory
if (indentLevel <= 1) {
break;
}
if (prevIndentLevel === null || indentLevel === prevIndentLevel - 1) {
const fileName = line.substring(index).trim();
path = fileName + path;
prevIndentLevel = indentLevel;
}
element = element.previousElementSibling;
}
return path;
}
function toggleFile(element) {
const patternInput = document.getElementById('pattern');
const patternFiles = patternInput.value
? patternInput.value.split(',').map((item) => item.trim())
: [];
const directoryContainer = document.getElementById('directory-structure-container');
const treeLineElements = Array.from(directoryContainer.children).filter(
(child) => child.tagName === 'PRE',
);
// Skip header and repository name
if (treeLineElements.slice(0, 2).includes(element)) {
return;
}
element.classList.toggle('line-through');
element.classList.toggle('text-gray-500');
const fileName = getFileName(element);
const idx = patternFiles.indexOf(fileName);
if (idx !== -1) {
patternFiles.splice(idx, 1);
} else {
patternFiles.push(fileName);
}
patternInput.value = patternFiles.join(', ');
}
function handleSubmit(event, showLoading = false) {
event.preventDefault();
const form = event.target || document.getElementById('ingestForm');
if (!form) return;
if (!form) {return;}
// Declare resultsSection before use
const resultsSection = document.querySelector('[data-results]');
if (resultsSection) {
// Show in-content loading spinner
// Show in-content loading spinner
resultsSection.innerHTML = `
<div class="relative mt-10">
<div class="w-full h-full absolute inset-0 bg-black rounded-xl translate-y-2 translate-x-2"></div>
@ -64,12 +125,14 @@ function handleSubmit(event, showLoading = false) {
}
const submitButton = form.querySelector('button[type="submit"]');
if (!submitButton) return;
if (!submitButton) {return;}
const formData = new FormData(form);
// Update file size
const slider = document.getElementById('file_size');
if (slider) {
formData.delete('max_file_size');
formData.append('max_file_size', slider.value);
@ -78,6 +141,7 @@ function handleSubmit(event, showLoading = false) {
// Update pattern type and pattern
const patternType = document.getElementById('pattern_type');
const pattern = document.getElementById('pattern');
if (patternType && pattern) {
formData.delete('pattern_type');
formData.delete('pattern');
@ -102,19 +166,20 @@ function handleSubmit(event, showLoading = false) {
}
// Submit the form to /api/ingest
fetch('/api/ingest', {method: 'POST', body: formData})
.then(response => response.json())
.then(data => {
fetch('/api/ingest', { method: 'POST', body: formData })
.then((response) => response.json())
.then((data) => {
// Hide loading overlay
if (resultsSection) resultsSection.innerHTML = '';
if (resultsSection) {resultsSection.innerHTML = '';}
submitButton.disabled = false;
submitButton.innerHTML = originalContent;
if (!resultsSection) return;
if (!resultsSection) {return;}
// Handle error
if (data.error) {
resultsSection.innerHTML = `<div class='mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700'>${data.error}</div>`;
return;
}
@ -190,20 +255,38 @@ function handleSubmit(event, showLoading = false) {
// Set plain text content for summary, tree, and content
document.getElementById('result-summary').value = data.summary || '';
document.getElementById('directory-structure-content').value = data.tree || '';
document.getElementById('directory-structure-pre').textContent = data.tree || '';
document.getElementById('result-content').value = data.content || '';
// Populate directory structure lines as clickable <pre> elements
const dirPre = document.getElementById('directory-structure-pre');
if (dirPre && data.tree) {
dirPre.innerHTML = '';
data.tree.split('\n').forEach((line) => {
const pre = document.createElement('pre');
pre.setAttribute('name', 'tree-line');
pre.className = 'cursor-pointer hover:line-through hover:text-gray-500';
pre.textContent = line;
pre.onclick = function () { toggleFile(this); };
dirPre.appendChild(pre);
});
}
// Scroll to results
resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
})
.catch(error => {
.catch((error) => {
// Hide loading overlay
if (resultsSection) resultsSection.innerHTML = '';
if (resultsSection) {
resultsSection.innerHTML = '';
}
submitButton.disabled = false;
submitButton.innerHTML = originalContent;
const resultsSection = document.querySelector('[data-results]');
if (resultsSection) {
resultsSection.innerHTML = `<div class='mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700'>${error}</div>`;
const errorContainer = document.querySelector('[data-results]');
if (errorContainer) {
errorContainer.innerHTML = `<div class='mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700'>${error}</div>`;
}
});
}
@ -226,9 +309,10 @@ function copyFullDigest() {
setTimeout(() => {
button.innerHTML = originalText;
}, 2000);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
})
.catch((err) => {
console.error('Failed to copy text: ', err);
});
}
function downloadFullDigest() {
@ -245,8 +329,9 @@ function downloadFullDigest() {
// Create a download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'codebase-digest.txt';
a.download = 'digest.txt';
document.body.appendChild(a);
a.click();
@ -275,7 +360,8 @@ function logSliderToSize(position) {
const maxPosition = 500;
const maxValue = Math.log(102400); // 100 MB
const value = Math.exp(maxValue * Math.pow(position / maxPosition, 1.5));
const value = Math.exp(maxValue * (position / maxPosition)**1.5);
return Math.round(value);
}
@ -284,10 +370,11 @@ function initializeSlider() {
const slider = document.getElementById('file_size');
const sizeValue = document.getElementById('size_value');
if (!slider || !sizeValue) return;
if (!slider || !sizeValue) {return;}
function updateSlider() {
const value = logSliderToSize(slider.value);
sizeValue.textContent = formatSize(value);
slider.style.backgroundSize = `${(slider.value / slider.max) * 100}% 100%`;
}
@ -302,24 +389,18 @@ function initializeSlider() {
// Add helper function for formatting size
function formatSize(sizeInKB) {
if (sizeInKB >= 1024) {
return Math.round(sizeInKB / 1024) + 'MB';
return `${ Math.round(sizeInKB / 1024) }MB`;
}
return Math.round(sizeInKB) + 'kB';
return `${ Math.round(sizeInKB) }kB`;
}
// Make sure these are available globally
window.copyText = copyText;
window.handleSubmit = handleSubmit;
window.initializeSlider = initializeSlider;
window.formatSize = formatSize;
window.downloadFullDigest = downloadFullDigest;
// Add this new function
function setupGlobalEnterHandler() {
document.addEventListener('keydown', function (event) {
document.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.target.matches('textarea')) {
const form = document.getElementById('ingestForm');
if (form) {
handleSubmit(new Event('submit'), true);
}
@ -332,3 +413,11 @@ document.addEventListener('DOMContentLoaded', () => {
initializeSlider();
setupGlobalEnterHandler();
});
// Make sure these are available globally
window.handleSubmit = handleSubmit;
window.toggleFile = toggleFile;
window.copyText = copyText;
window.copyFullDigest = copyFullDigest;
window.downloadFullDigest = downloadFullDigest;

View file

@ -1,6 +0,0 @@
module.exports = {
content: [
"./src/**/*.jinja",
"./src/**/*.js",
],
};

View file

@ -91,9 +91,10 @@ async def test_clone_nonexistent_repository(repo_exists_true: AsyncMock) -> None
@pytest.mark.parametrize(
("status_code", "expected"),
[
(b"200\n", 0, True), # Existing repo
(b"404\n", 0, False), # Non-existing repo
(b"200\n", 1, False), # Failed request
(HTTP_200_OK, True),
(HTTP_401_UNAUTHORIZED, False),
(HTTP_403_FORBIDDEN, False),
(HTTP_404_NOT_FOUND, False),
],
)
async def test_check_repo_exists(status_code: int, *, expected: bool, mocker: MockerFixture) -> None:
@ -208,25 +209,6 @@ async def test_check_repo_exists_with_redirect(mocker: MockerFixture) -> None:
assert repo_exists is False
@pytest.mark.asyncio
async def test_check_repo_exists_with_permanent_redirect(mocker: MockerFixture) -> None:
"""Test ``check_repo_exists`` when a permanent redirect (301) is returned.
Given a URL that responds with "301 Found":
When ``check_repo_exists`` is called,
Then it should return ``True``, indicating the repo may exist at the new location.
"""
mock_exec = mocker.patch("asyncio.create_subprocess_exec", new_callable=AsyncMock)
mock_process = AsyncMock()
mock_process.communicate.return_value = (b"301\n", b"")
mock_process.returncode = 0 # Simulate successful request
mock_exec.return_value = mock_process
repo_exists = await check_repo_exists(DEMO_URL)
assert repo_exists
@pytest.mark.asyncio
async def test_clone_with_timeout(run_command_mock: AsyncMock) -> None:
"""Test cloning a repository when a timeout occurs.