Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Integration Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
integration:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Set up Python
run: uv python install

- name: Install dependencies
run: uv sync --dev

- name: Run integration tests
run: uv run pytest tests/ -v --log-cli-level=INFO -m integration
38 changes: 38 additions & 0 deletions .github/workflows/python-lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Python Lint

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: Ruff & Pyright
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Set up Python
run: uv python install

- name: Install dependencies
run: uv sync --dev

- name: Ruff check (lint)
run: uv run ruff check .

- name: Ruff format (formatting check)
run: uv run ruff format --check .

- name: Pyright (type check)
run: uv run pyright
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@

# OpenCode local plugin (installed by agentblame init)
.opencode/

# Python
__pycache__/
.pytest_cache/
*.pyc
.venv/
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
32 changes: 32 additions & 0 deletions Dockerfile.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Stage 1: Build the git-fs binary
FROM rust:1.93-bookworm AS builder

RUN apt-get update && apt-get install -y \
pkg-config \
libfuse3-dev \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /src
COPY Cargo.toml Cargo.lock build.rs ./
COPY .git/ .git/
COPY src/ src/

RUN cargo build --release

# Stage 2: Runtime image
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y \
fuse3 \
ca-certificates \
findutils \
&& rm -rf /var/lib/apt/lists/*

# Allow non-root users to mount FUSE filesystems
RUN sed -i 's/#user_allow_other/user_allow_other/' /etc/fuse.conf || true

COPY --from=builder /src/target/release/git-fs /usr/local/bin/git-fs

RUN mkdir -p /mnt/git-fs

CMD ["tail", "-f", "/dev/null"]
59 changes: 59 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
[project]
name = "git-fs-tests"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = []

[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-timeout>=2.4.0",
"testcontainers>=4.14.1",
"ruff>=0.9.0",
"pyright>=1.1.390",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
timeout = 300
markers = [
"integration: marks tests as integration tests",
]

[tool.ruff]
target-version = "py314"
line-length = 120

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort (import sorting)
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"RUF", # ruff-specific rules
"PT", # flake8-pytest-style
"T20", # flake8-print
"LOG", # flake8-logging
]

[tool.ruff.lint.per-file-ignores]
"main.py" = ["T201"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.pyright]
pythonVersion = "3.14"
typeCheckingMode = "strict"
include = ["main.py", "tests"]
reportMissingTypeStubs = false
reportUnknownMemberType = false
reportUnknownVariableType = false
reportUnknownArgumentType = false
reportUnknownParameterType = false
Empty file added tests/__init__.py
Empty file.
138 changes: 138 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Shared fixtures for git-fs integration tests."""

from __future__ import annotations

import logging
import os
import subprocess
import textwrap
import time
from collections.abc import Generator
from pathlib import Path

import pytest
from testcontainers.core.container import DockerContainer

# Disable Ryuk (testcontainers' crash-recovery sidecar). The context manager
# on DockerContainer already handles cleanup on normal/exception exit.
os.environ["TESTCONTAINERS_RYUK_DISABLED"] = "true"

logger = logging.getLogger(__name__)

MOUNT_POINT = "/mnt/git-fs"
GITFS_READY_TIMEOUT = 60
GITFS_READY_POLL_INTERVAL = 2

IMAGE_TAG = "git-fs-integration-test:latest"

REPO_ROOT = Path(__file__).resolve().parent.parent
DOCKERFILE_PATH = REPO_ROOT / "Dockerfile.test"


@pytest.fixture(scope="session")
def gitfs_image() -> str:
"""Build the git-fs test Docker image once per test session via docker CLI."""
logger.info("Building Docker image from %s", REPO_ROOT)
subprocess.run(
[
"docker",
"build",
"-f",
str(DOCKERFILE_PATH),
"-t",
IMAGE_TAG,
str(REPO_ROOT),
],
check=True,
timeout=600,
)
return IMAGE_TAG


def _generate_config_toml() -> str:
return textwrap.dedent(f"""\
mount-point = "{MOUNT_POINT}"
uid = 0
gid = 0

[cache]
path = "/tmp/git-fs-cache"

[daemon]
pid-file = "/tmp/git-fs.pid"
""")


def _wait_for_mount(container: DockerContainer, timeout: int = GITFS_READY_TIMEOUT) -> None:
"""Wait until the FUSE mount is up."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
exit_code, output = container.exec(["ls", MOUNT_POINT])
decoded = output.decode("utf-8", errors="replace").strip()
if exit_code == 0 and "github" in decoded:
logger.info("git-fs mount is ready (ls %s: %s)", MOUNT_POINT, decoded)
return
time.sleep(GITFS_READY_POLL_INTERVAL)
exit_code, output = container.exec(["ls", "-la", MOUNT_POINT])
raise TimeoutError(
f"git-fs mount did not become ready within {timeout}s. "
f"Mount point listing (exit={exit_code}): {output.decode('utf-8', errors='replace')}"
)


@pytest.fixture(scope="session")
def gitfs_container(gitfs_image: str) -> Generator[DockerContainer]:
"""Start a privileged container with FUSE, write config, launch git-fs."""
container = DockerContainer(gitfs_image).with_kwargs(privileged=True)

with container:
# Write config
config_content = _generate_config_toml()
container.exec(["mkdir", "-p", "/etc/git-fs"])
# Review: Is there no create-file method on the container?
container.exec(
[
"sh",
"-c",
f"cat > /etc/git-fs/config.toml << 'HEREDOC'\n{config_content}\nHEREDOC",
]
)

# Review: Is there a point to this?
exit_code, output = container.exec(["cat", "/etc/git-fs/config.toml"])
assert exit_code == 0, f"Failed to write config: {output}"
logger.info("Config written:\n%s", output.decode())

# Review: We shouldn't need to create the mount point. git-fs should handle it.
container.exec(["mkdir", "-p", MOUNT_POINT])
container.exec(
[
"sh",
"-c",
"GIT_FS_LOG=debug nohup git-fs --config-path /etc/git-fs/config.toml run "
"> /tmp/git-fs-stdout.log 2> /tmp/git-fs-stderr.log &",
]
)

_wait_for_mount(container)
yield container

# Diagnostic logs on teardown — DEBUG level so they only appear
# in pytest output when a test fails (pytest captures and replays).
_, stdout_log = container.exec(["cat", "/tmp/git-fs-stdout.log"])
_, stderr_log = container.exec(["cat", "/tmp/git-fs-stderr.log"])
logger.debug("git-fs stdout:\n%s", stdout_log.decode("utf-8", errors="replace"))
logger.debug("git-fs stderr:\n%s", stderr_log.decode("utf-8", errors="replace"))


def clone_repo(repo_slug: str, dest: Path) -> Path:
"""Clone a GitHub repo at depth 1 into dest."""
url = f"https://github.com/{repo_slug}.git"
logger.info("Cloning %s into %s", url, dest)
subprocess.run(
["git", "clone", "--depth", "1", url, str(dest)],
check=True,
capture_output=True,
timeout=120,
)
return dest
Loading
Loading