diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a6ced0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,63 @@ +# Docker ignore file for Samoyed +# Reduces build context size and improves build performance + +# Git +.git/ +.gitignore +.github/ + +# Rust build artifacts +target/ +**/*.rs.bk +*.pdb + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Documentation +README.md +CLAUDE.md +AGENTS.md +.docs/ +.assets/ + +# Nix +flake.nix +flake.lock +result +result-* + +# CI/CD (except workflows we might need) +.gitlab-ci.yml +.travis.yml + +# Testing artifacts +.tarpaulin.toml +tarpaulin-report.html +cobertura.xml +lcov.info + +# Temporary files +*.log +*.tmp +/tmp/ + +# Package files +*.deb +*.rpm +*.tar.gz + +# macOS +.DS_Store + +# Keep these for the build: +# - Cargo.toml +# - Cargo.lock +# - src/ +# - assets/ +# - clippy.toml +# - tests/integration/ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..793118d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,50 @@ +name: Deploy Documentation + +on: + push: + branches: ["main"] + paths: + - 'docs/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup mdBook + uses: peaceiris/actions-mdbook@v2 + with: + mdbook-version: 'latest' + + - name: Build book + run: | + cd docs + mdbook build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './docs/book' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 504ab0b..c1bce1e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ target/ # Claude Code local settings - user-specific configurations that should # not be shared across development environments .claude/settings.local.json + +# ============================================================================ +# Documentation Build Output +# ============================================================================ +# mdBook generated HTML output directory +docs/book/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e47a782 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# Dockerfile for Samoyed integration tests +# Multi-stage build for efficient parallel testing + +# Stage 1: Build Samoyed binary +FROM rust:1.83-slim AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && \ + apt-get install -y pkg-config libssl-dev && \ + rm -rf /var/lib/apt/lists/* + +# Copy dependency files first (better caching) +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +COPY assets ./assets +COPY clippy.toml ./ + +# Build release binary +RUN cargo build --release --verbose && \ + strip target/release/samoyed + +# Stage 2: Test runtime environment +FROM debian:bookworm-slim AS test-runner + +# Install minimal dependencies for tests +RUN apt-get update && \ + apt-get install -y \ + git \ + bash \ + coreutils \ + ca-certificates \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Configure git for tests (disable commit signing) +RUN git config --global commit.gpgsign false && \ + git config --global user.email "test@samoyed.test" && \ + git config --global user.name "Samoyed Test" + +# Copy compiled binary from builder +COPY --from=builder /build/target/release/samoyed /usr/local/bin/samoyed + +# Copy test suite +COPY tests/integration /tests/integration + +# Set up test workspace (isolated per container) +WORKDIR /test-workspace + +# Verify binary works +RUN samoyed --version + +# Environment variables +ENV TEST_NAME="" +ENV SAMOYED_TEST_CONTAINER=1 + +# Default: run specific test passed via environment variable +CMD if [ -n "$TEST_NAME" ]; then \ + exec /tests/integration/"$TEST_NAME"; \ + else \ + echo "ERROR: TEST_NAME environment variable not set"; \ + echo "Usage: docker run -e TEST_NAME=01_default.sh samoyed-test"; \ + exit 1; \ + fi diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5ec8d27 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +# Makefile for Samoyed +# Provides convenient commands for building, testing, and development + +.PHONY: help build test test-docker test-docker-parallel test-docker-compose clean + +# Default target +help: + @echo "Samoyed - Git hooks manager" + @echo "" + @echo "Available targets:" + @echo " make build - Build release binary" + @echo " make test - Run unit tests" + @echo " make test-integration - Run integration tests (serial)" + @echo " make test-docker - Run integration tests in Docker (serial)" + @echo " make test-docker-parallel - Run integration tests in Docker (parallel)" + @echo " make test-docker-compose - Run integration tests via Docker Compose" + @echo " make clean - Clean build artifacts" + @echo " make fmt - Format code" + @echo " make clippy - Run Clippy linter" + @echo " make coverage - Generate test coverage report" + +# Build release binary +build: + cargo build --release --verbose + +# Run Rust unit tests +test: + cargo test --verbose -- --test-threads=1 + +# Run integration tests locally (serial) +test-integration: build + @echo "Running integration tests..." + @cd tests/integration && for test in [0-9]*.sh; do \ + echo "Running $$test..."; \ + ./$$test || exit 1; \ + done + +# Build and test in Docker (serial) +test-docker: + docker build -t samoyed-test:latest -f Dockerfile . + @for test in tests/integration/[0-9]*.sh; do \ + test_name=$$(basename $$test); \ + echo "Running $$test_name in Docker..."; \ + docker run --rm -e TEST_NAME=$$test_name samoyed-test:latest || exit 1; \ + done + +# Build and test in Docker (parallel) +test-docker-parallel: + @bash tests/integration/run-parallel-docker.sh + +# Test using Docker Compose +test-docker-compose: + docker-compose -f docker-compose.test.yml build + docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from test-01-default + docker-compose -f docker-compose.test.yml down + +# Clean build artifacts +clean: + cargo clean + rm -rf target/ + rm -f *.log + +# Format code +fmt: + cargo fmt --all + +# Run Clippy linter +clippy: + cargo clippy --all-targets --all-features -- -D warnings + +# Generate test coverage +coverage: + cargo tarpaulin -- --test-threads=1 diff --git a/README.md b/README.md index 9b992a7..9374839 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Samoyed [![Crates.io Version](https://img.shields.io/crates/v/samoyed)](https://crates.io/crates/samoyed) +[![Documentation](https://img.shields.io/badge/docs-mdbook-blue)](https://nutthead.github.io/samoyed/) > A single-binary, minimalist, ultra-fast Git hooks manager for every platform. diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..04a34c4 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,125 @@ +version: '3.8' + +# Docker Compose configuration for parallel integration testing +# Usage: docker-compose -f docker-compose.test.yml up --abort-on-container-exit + +services: + test-01-default: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-01-default + environment: + TEST_NAME: 01_default.sh + + test-02-custom-dir: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-02-custom-dir + environment: + TEST_NAME: 02_custom_dir.sh + + test-03-from-subdir: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-03-from-subdir + environment: + TEST_NAME: 03_from_subdir.sh + + test-04-not-git-dir: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-04-not-git-dir + environment: + TEST_NAME: 04_not_git_dir.sh + + test-05-git-not-found: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-05-git-not-found + environment: + TEST_NAME: 05_git_not_found.sh + + test-06-command-not-found: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-06-command-not-found + environment: + TEST_NAME: 06_command_not_found.sh + + test-07-strict-mode: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-07-strict-mode + environment: + TEST_NAME: 07_strict_mode.sh + + test-08-samoyed-0: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-08-samoyed-0 + environment: + TEST_NAME: 08_samoyed_0.sh + + test-09-init: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-09-init + environment: + TEST_NAME: 09_init.sh + + test-10-time: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-10-time + environment: + TEST_NAME: 10_time.sh + + test-11-lfs-flags: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-11-lfs-flags + environment: + TEST_NAME: 11_lfs_flags.sh + + test-12-lfs-subcommand: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-12-lfs-subcommand + environment: + TEST_NAME: 12_lfs_subcommand.sh + + test-13-hooks-d: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-13-hooks-d + environment: + TEST_NAME: 13_hooks_d.sh + + test-14-existing-hooks: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-14-existing-hooks + environment: + TEST_NAME: 14_existing_hooks.sh + + test-15-combined-features: + build: + context: . + dockerfile: Dockerfile + container_name: samoyed-test-15-combined-features + environment: + TEST_NAME: 15_combined_features.sh diff --git a/docs/book.toml b/docs/book.toml new file mode 100644 index 0000000..1cd84a5 --- /dev/null +++ b/docs/book.toml @@ -0,0 +1,26 @@ +[book] +title = "Samoyed Documentation" +authors = ["Samoyed Contributors"] +description = "Documentation for Samoyed, a minimal, cross-platform Git hooks manager" +language = "en" +multilingual = false +src = "src" + +[output.html] +default-theme = "light" +preferred-dark-theme = "navy" +git-repository-url = "https://github.com/nutthead/samoyed" +git-repository-icon = "fa-github" +edit-url-template = "https://github.com/nutthead/samoyed/edit/main/docs/{path}" + +[output.html.search] +enable = true +limit-results = 30 +use-boolean-and = true + +[output.html.fold] +enable = true +level = 1 + +[output.html.print] +enable = true diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md new file mode 100644 index 0000000..86ebed1 --- /dev/null +++ b/docs/src/SUMMARY.md @@ -0,0 +1,63 @@ +# Summary + +[Introduction](./introduction.md) + +# Getting Started + +- [Installation](./installation.md) +- [Quick Start](./quick-start.md) +- [Upgrading](./upgrading.md) + +# Core Features + +- [Basic Usage](./basic-usage.md) + - [Initializing Hooks](./basic-usage.md#initializing-hooks) + - [Creating Hooks](./basic-usage.md#creating-hooks) + - [Running Hooks](./basic-usage.md#running-hooks) +- [Custom Directories](./custom-directories.md) +- [Environment Variables](./environment-variables.md) + +# Advanced Features + +- [Git LFS Integration](./lfs-integration.md) + - [Automatic Detection](./lfs-integration.md#automatic-detection) + - [Manual Control](./lfs-integration.md#manual-control) + - [Managing LFS](./lfs-integration.md#managing-lfs) +- [Hook Composition](./hook-composition.md) + - [hooks.d Pattern](./hook-composition.md#hooksd-pattern) + - [Execution Order](./hook-composition.md#execution-order) + - [Importing Existing Hooks](./hook-composition.md#importing-existing-hooks) +- [Combined Features](./combined-features.md) + +# Guides + +- [Common Use Cases](./use-cases.md) + - [Code Formatting](./use-cases.md#code-formatting) + - [Commit Message Validation](./use-cases.md#commit-message-validation) + - [Running Tests](./use-cases.md#running-tests) + - [Pre-push Checks](./use-cases.md#pre-push-checks) +- [Migration Guide](./migration.md) + - [From Husky](./migration.md#from-husky) + - [From Other Tools](./migration.md#from-other-tools) +- [Best Practices](./best-practices.md) + +# Reference + +- [Command Reference](./commands.md) + - [init](./commands.md#init) + - [lfs](./commands.md#lfs) +- [Hook Reference](./hook-reference.md) +- [Configuration](./configuration.md) +- [Troubleshooting](./troubleshooting.md) + +# Development + +- [Contributing](./contributing.md) +- [Architecture](./architecture.md) +- [Testing](./testing.md) + +# Appendix + +- [FAQ](./faq.md) +- [Changelog](./changelog.md) +- [License](./license.md) diff --git a/docs/src/architecture.md b/docs/src/architecture.md new file mode 100644 index 0000000..a7cd5b2 --- /dev/null +++ b/docs/src/architecture.md @@ -0,0 +1,3 @@ +# Architecture + +Documentation coming soon. diff --git a/docs/src/basic-usage.md b/docs/src/basic-usage.md new file mode 100644 index 0000000..9ae9408 --- /dev/null +++ b/docs/src/basic-usage.md @@ -0,0 +1,85 @@ +# Basic Usage + +Learn the fundamentals of using Samoyed to manage your Git hooks. + +## Initializing Hooks + +Start by initializing Samoyed in your Git repository: + +```bash +cd your-project +samoyed init +``` + +This is covered in detail in the [Quick Start](./quick-start.md) guide. + +## Creating Hooks + +Hooks are executable scripts in the `.samoyed/` directory, named after the Git hook they implement. + +### Supported Hooks + +Samoyed supports all 14 standard Git hooks: + +- `applypatch-msg` +- `pre-applypatch` +- `post-applypatch` +- `pre-commit` +- `prepare-commit-msg` (not generated by default) +- `commit-msg` +- `post-commit` +- `pre-rebase` (not generated by default) +- `post-checkout` +- `post-merge` +- `pre-push` +- `pre-receive` (server-side, not generated) +- `update` (server-side, not generated) +- `post-receive` (server-side, not generated) +- `post-update` (server-side, not generated) +- `pre-auto-gc` +- `post-rewrite` +- `pre-merge-commit` +- `reference-transaction` (not generated by default) +- `push-to-checkout` (not generated by default) +- `pre-push` (generated by default with sample) +- `sendemail-validate` (not generated by default) + +Samoyed generates wrappers for the most commonly used hooks. + +## Running Hooks + +Hooks run automatically when you perform the corresponding Git operation: + +```bash +git commit # Runs pre-commit and commit-msg +git push # Runs pre-push +git merge # Runs post-merge +``` + +## Bypassing Hooks + +Sometimes you need to skip hooks: + +```bash +# Bypass all Samoyed hooks +SAMOYED=0 git commit -m "emergency fix" + +# Or use Git's --no-verify flag +git commit --no-verify -m "skip hooks" +``` + +## Debugging Hooks + +Enable debug mode to see what's happening: + +```bash +SAMOYED=2 git commit -m "test" +``` + +This enables `set -x` in the wrapper script, showing each command as it executes. + +## Next Steps + +- [Custom Directories](./custom-directories.md) +- [Environment Variables](./environment-variables.md) +- [Common Use Cases](./use-cases.md) diff --git a/docs/src/best-practices.md b/docs/src/best-practices.md new file mode 100644 index 0000000..67ed3bb --- /dev/null +++ b/docs/src/best-practices.md @@ -0,0 +1,3 @@ +# Best Practices + +Documentation coming soon. diff --git a/docs/src/changelog.md b/docs/src/changelog.md new file mode 100644 index 0000000..045783d --- /dev/null +++ b/docs/src/changelog.md @@ -0,0 +1,3 @@ +# Changelog + +Documentation coming soon. diff --git a/docs/src/chapter_1.md b/docs/src/chapter_1.md new file mode 100644 index 0000000..b743fda --- /dev/null +++ b/docs/src/chapter_1.md @@ -0,0 +1 @@ +# Chapter 1 diff --git a/docs/src/combined-features.md b/docs/src/combined-features.md new file mode 100644 index 0000000..fbe1c1f --- /dev/null +++ b/docs/src/combined-features.md @@ -0,0 +1,3 @@ +# Combined Features + +Documentation coming soon. diff --git a/docs/src/commands.md b/docs/src/commands.md new file mode 100644 index 0000000..b1485e9 --- /dev/null +++ b/docs/src/commands.md @@ -0,0 +1,179 @@ +# Command Reference + +Complete reference for all Samoyed commands. + +## samoyed init + +Initialize Git hooks in a repository. + +### Syntax + +```bash +samoyed init [OPTIONS] [DIRNAME] +``` + +### Arguments + +- `[DIRNAME]` - Optional custom directory name (default: `.samoyed`) + +### Options + +- `--with-lfs` - Force enable Git LFS integration +- `--no-lfs` - Force disable Git LFS integration +- `--hooks-d` - Enable hook composition mode + +### Examples + +```bash +# Basic initialization +samoyed init + +# Custom directory +samoyed init my-hooks + +# With LFS integration +samoyed init --with-lfs + +# With hook composition +samoyed init --hooks-d + +# Combined features +samoyed init --with-lfs --hooks-d +``` + +### What It Does + +1. Detects Git repository root +2. Creates hook directory structure +3. Installs wrapper scripts +4. Configures `git config core.hooksPath` +5. Creates sample pre-commit hook +6. Optionally integrates Git LFS +7. Optionally sets up hooks.d/ + +### Exit Codes + +- `0` - Success +- `1` - Error (not a git repository, permission denied, etc.) + +## samoyed lfs + +Manage Git LFS integration. + +### Subcommands + +#### lfs status + +Show LFS integration status. + +```bash +samoyed lfs status +``` + +Output example: +``` +Git LFS installation: ✓ +Repository LFS config: ✓ Configured +Samoyed hooks initialized: ✓ in .samoyed/_ +LFS integration: Enabled +Hook composition mode: Disabled +``` + +#### lfs enable + +Enable LFS integration in existing hooks. + +```bash +samoyed lfs enable +``` + +Requirements: +- Samoyed must be initialized +- Git LFS should be installed + +#### lfs disable + +Disable LFS integration. + +```bash +samoyed lfs disable +``` + +Removes LFS commands from hooks while preserving other logic. + +### Examples + +```bash +# Check current status +samoyed lfs status + +# Enable LFS +samoyed lfs enable + +# Disable LFS +samoyed lfs disable +``` + +## Global Options + +### --help, -h + +Show help message. + +```bash +samoyed --help +samoyed init --help +samoyed lfs --help +``` + +### --version, -V + +Show version information. + +```bash +samoyed --version +``` + +## Environment Variables + +These environment variables affect Samoyed's behavior: + +### SAMOYED + +Control hook execution: + +- `SAMOYED=0` - Bypass all hooks (emergency mode) +- `SAMOYED=2` - Enable debug mode (verbose shell output) + +```bash +# Skip hooks once +SAMOYED=0 git commit -m "emergency fix" + +# Debug hooks +SAMOYED=2 git commit -m "test" +``` + +### XDG_CONFIG_HOME + +Override config directory location (default: `~/.config`): + +```bash +export XDG_CONFIG_HOME=~/my-config +``` + +Config file: `${XDG_CONFIG_HOME}/samoyed/init.sh` + +## Exit Codes + +Standard exit codes used by Samoyed: + +- `0` - Success +- `1` - General error +- `2` - Command line usage error + +## See Also + +- [Basic Usage](./basic-usage.md) +- [Git LFS Integration](./lfs-integration.md) +- [Hook Composition](./hook-composition.md) +- [Environment Variables](./environment-variables.md) diff --git a/docs/src/configuration.md b/docs/src/configuration.md new file mode 100644 index 0000000..c9f372c --- /dev/null +++ b/docs/src/configuration.md @@ -0,0 +1,3 @@ +# Configuration + +Documentation coming soon. diff --git a/docs/src/contributing.md b/docs/src/contributing.md new file mode 100644 index 0000000..54914a7 --- /dev/null +++ b/docs/src/contributing.md @@ -0,0 +1,3 @@ +# Contributing + +Documentation coming soon. diff --git a/docs/src/custom-directories.md b/docs/src/custom-directories.md new file mode 100644 index 0000000..5e361dd --- /dev/null +++ b/docs/src/custom-directories.md @@ -0,0 +1,3 @@ +# Custom Directories + +Documentation coming soon. diff --git a/docs/src/environment-variables.md b/docs/src/environment-variables.md new file mode 100644 index 0000000..450ccc4 --- /dev/null +++ b/docs/src/environment-variables.md @@ -0,0 +1,3 @@ +# Environment Variables + +Documentation coming soon. diff --git a/docs/src/faq.md b/docs/src/faq.md new file mode 100644 index 0000000..cddd591 --- /dev/null +++ b/docs/src/faq.md @@ -0,0 +1,3 @@ +# Faq + +Documentation coming soon. diff --git a/docs/src/hook-composition.md b/docs/src/hook-composition.md new file mode 100644 index 0000000..d22ae14 --- /dev/null +++ b/docs/src/hook-composition.md @@ -0,0 +1,394 @@ +# Hook Composition + +Hook composition allows you to run multiple hook scripts in sequence using the `hooks.d/` pattern. This is perfect for complex workflows, team environments, or when migrating from other tools. + +## Overview + +Instead of writing one large hook script, you can split your hooks into multiple smaller scripts that run in sequence. This makes hooks: + +- **Modular**: Each script does one thing +- **Maintainable**: Easy to add, remove, or modify individual checks +- **Composable**: Mix hooks from different sources (LFS, imported, custom) +- **Organized**: Clear naming and execution order + +## Enabling Hook Composition + +### New Initialization + +```bash +samoyed init --hooks-d +``` + +This creates: +- `.samoyed/hooks.d/` - directory for multiple hook scripts +- Modified hooks that scan and run `hooks.d/` scripts + +### Existing Setup + +If you already have Samoyed initialized: + +```bash +# Remove old setup +rm -rf .samoyed +git config --unset core.hooksPath + +# Re-initialize with hooks.d +samoyed init --hooks-d +``` + +## hooks.d Pattern + +### Directory Structure + +``` +.samoyed/ +├── hooks.d/ +│ ├── 10-format.pre-commit +│ ├── 20-lint.pre-commit +│ ├── 30-test.pre-commit +│ ├── 50-imported.pre-push +│ └── 99-notify.post-commit +├── pre-commit # Your main hook (runs last) +├── pre-push +└── _/ # Generated wrappers + ├── pre-commit + ├── pre-push + └── samoyed +``` + +### Naming Convention + +Files in `hooks.d/` must follow this pattern: + +``` +-. +``` + +Examples: +- `10-format.pre-commit` - Runs first in pre-commit +- `20-lint.pre-commit` - Runs second in pre-commit +- `50-imported.pre-push` - Runs during pre-push +- `99-notify.post-commit` - Runs last in post-commit + +**Priority determines execution order** (lexicographic sort): +- `10-*` runs before `20-*` +- `20-*` runs before `50-*` +- etc. + +## Execution Order + +For each Git hook, scripts run in this order: + +1. **LFS commands** (if [LFS integration](./lfs-integration.md) enabled) +2. **hooks.d/ scripts** (in lexicographic order) +3. **Main hook** (`.samoyed/`) + +### Example Flow + +With this setup: +``` +.samoyed/hooks.d/10-format.pre-commit +.samoyed/hooks.d/20-test.pre-commit +.samoyed/pre-commit +``` + +When you run `git commit`: +``` +1. Git LFS commands (if enabled) +2. hooks.d/10-format.pre-commit +3. hooks.d/20-test.pre-commit +4. .samoyed/pre-commit +``` + +## Creating Composed Hooks + +### Example 1: Code Quality Pipeline + +```bash +# Format check +cat > .samoyed/hooks.d/10-format.pre-commit <<'EOF' +#!/usr/bin/env sh +echo "→ Checking code formatting..." +cargo fmt -- --check || exit 1 +echo "✓ Format OK" +EOF +chmod +x .samoyed/hooks.d/10-format.pre-commit + +# Lint check +cat > .samoyed/hooks.d/20-lint.pre-commit <<'EOF' +#!/usr/bin/env sh +echo "→ Running linter..." +cargo clippy -- -D warnings || exit 1 +echo "✓ Lint OK" +EOF +chmod +x .samoyed/hooks.d/20-lint.pre-commit + +# Tests +cat > .samoyed/hooks.d/30-test.pre-commit <<'EOF' +#!/usr/bin/env sh +echo "→ Running tests..." +cargo test --quiet || exit 1 +echo "✓ Tests OK" +EOF +chmod +x .samoyed/hooks.d/30-test.pre-commit +``` + +### Example 2: Multi-Language Project + +```bash +# Rust checks +cat > .samoyed/hooks.d/10-rust.pre-commit <<'EOF' +#!/usr/bin/env sh +if git diff --cached --name-only | grep -q '\.rs$'; then + echo "→ Checking Rust..." + cargo fmt --check && cargo clippy +fi +EOF +chmod +x .samoyed/hooks.d/10-rust.pre-commit + +# JavaScript checks +cat > .samoyed/hooks.d/20-js.pre-commit <<'EOF' +#!/usr/bin/env sh +if git diff --cached --name-only | grep -q '\.js$'; then + echo "→ Checking JavaScript..." + npm run lint && npm test +fi +EOF +chmod +x .samoyed/hooks.d/20-js.pre-commit + +# Python checks +cat > .samoyed/hooks.d/30-python.pre-commit <<'EOF' +#!/usr/bin/env sh +if git diff --cached --name-only | grep -q '\.py$'; then + echo "→ Checking Python..." + black --check . && pylint **/*.py +fi +EOF +chmod +x .samoyed/hooks.d/30-python.pre-commit +``` + +## Importing Existing Hooks + +When you initialize with `--hooks-d`, Samoyed can import existing hooks from other tools. + +### Automatic Import + +```bash +# Samoyed detects existing hooks +samoyed init --hooks-d +``` + +Output: +``` +Warning: Existing Git hooks detected in .git/hooks + - pre-commit + - pre-push + +These hooks will become inactive after initialization. +Use --hooks-d to import them to hooks.d/ + +Importing existing hooks... + ✓ Imported pre-commit → .samoyed/hooks.d/50-imported.pre-commit + ✓ Imported pre-push → .samoyed/hooks.d/50-imported.pre-push +``` + +Imported hooks use priority `50` by default, running after your custom checks but before the main hook. + +### Custom Hooks Path + +If your repository uses a custom `core.hooksPath`: + +```bash +git config core.hooksPath .githooks +``` + +Samoyed detects and imports from there: + +```bash +samoyed init --hooks-d +``` + +Output: +``` +Warning: Existing Git hooks detected in .githooks + - pre-commit + +Importing existing hooks... + ✓ Imported pre-commit → .samoyed/hooks.d/50-imported.pre-commit +``` + +## Failure Handling + +### Stop on First Failure + +By default, if any hook fails, the rest don't run: + +```bash +# hooks.d/10-format.pre-commit fails → execution stops +# hooks.d/20-test.pre-commit doesn't run +# .samoyed/pre-commit doesn't run +``` + +This is the **recommended behavior** for most cases. + +### Continue on Failure (Advanced) + +If you need a hook to run but not fail the operation: + +```bash +cat > .samoyed/hooks.d/10-optional.pre-commit <<'EOF' +#!/usr/bin/env sh +some-command || true # Always exit 0 +EOF +chmod +x .samoyed/hooks.d/10-optional.pre-commit +``` + +## Best Practices + +### Naming + +Use descriptive names and priorities: +- `10-format` - Fast checks first +- `20-lint` - Medium checks +- `30-test` - Slower checks last +- `50-imported` - Imported hooks +- `99-notify` - Notifications (always last) + +### Priority Ranges + +Suggested priority ranges: +- **00-09**: Pre-flight checks (fast, essential) +- **10-29**: Format and lint (fast feedback) +- **30-49**: Tests and builds (slower) +- **50-59**: Imported hooks +- **60-89**: Integration checks +- **90-99**: Notifications and logging + +### Permissions + +Always make hooks executable: +```bash +chmod +x .samoyed/hooks.d/*.pre-commit +``` + +Non-executable files are silently skipped. + +### Testing + +Test individual hooks: +```bash +# Run a specific hook +.samoyed/hooks.d/10-format.pre-commit + +# Test the full chain +git commit --allow-empty -m "test" +``` + +## Real-World Example + +A complete pre-commit pipeline: + +```bash +# 00-fast: Quick sanity checks +cat > .samoyed/hooks.d/00-fast.pre-commit <<'EOF' +#!/usr/bin/env sh +# Check for merge conflicts +if git diff --cached | grep -E '^(<<<<<<<|=======|>>>>>>>)'; then + echo "❌ Merge conflict markers found" + exit 1 +fi +EOF + +# 10-format: Code formatting +cat > .samoyed/hooks.d/10-format.pre-commit <<'EOF' +#!/usr/bin/env sh +echo "→ Checking format..." +cargo fmt -- --check +EOF + +# 20-lint: Linting +cat > .samoyed/hooks.d/20-lint.pre-commit <<'EOF' +#!/usr/bin/env sh +echo "→ Running clippy..." +cargo clippy -- -D warnings +EOF + +# 30-test: Unit tests +cat > .samoyed/hooks.d/30-test.pre-commit <<'EOF' +#!/usr/bin/env sh +echo "→ Running tests..." +cargo test --quiet +EOF + +# Make them all executable +chmod +x .samoyed/hooks.d/*.pre-commit +``` + +Result: +```bash +$ git commit -m "add feature" +→ Checking format... +✓ Format OK +→ Running clippy... +✓ Lint OK +→ Running tests... +✓ Tests OK +[main abc123] add feature +``` + +## Combined with LFS + +Use both hooks.d and LFS integration: + +```bash +samoyed init --with-lfs --hooks-d +``` + +Execution order: +1. Git LFS commands +2. hooks.d/ scripts (your checks) +3. Main hook (if exists) + +See [Combined Features](./combined-features.md) for details. + +## Troubleshooting + +### Hooks not running? + +Check they're executable: +```bash +ls -l .samoyed/hooks.d/ +``` + +Make them executable: +```bash +chmod +x .samoyed/hooks.d/*.pre-commit +``` + +### Wrong execution order? + +Check filenames - order is lexicographic: +```bash +ls .samoyed/hooks.d/*.pre-commit +``` + +Should show: +``` +10-format.pre-commit +20-lint.pre-commit +30-test.pre-commit +``` + +### Hook failing silently? + +Check the hook's exit code: +```bash +.samoyed/hooks.d/10-format.pre-commit +echo $? # Should be 0 for success +``` + +## Next Steps + +- [Combined Features](./combined-features.md): Use hooks.d with LFS +- [Best Practices](./best-practices.md): Write great hooks +- [Use Cases](./use-cases.md): Real-world examples diff --git a/docs/src/hook-reference.md b/docs/src/hook-reference.md new file mode 100644 index 0000000..fae652b --- /dev/null +++ b/docs/src/hook-reference.md @@ -0,0 +1,3 @@ +# Hook Reference + +Documentation coming soon. diff --git a/docs/src/installation.md b/docs/src/installation.md new file mode 100644 index 0000000..5d53c10 --- /dev/null +++ b/docs/src/installation.md @@ -0,0 +1,127 @@ +# Installation + +Samoyed can be installed through several methods. Choose the one that works best for your workflow. + +## From Pre-built Binaries + +**Recommended for most users** + +Download the latest release from [GitHub Releases](https://github.com/nutthead/samoyed/releases): + +```bash +# Linux (x86_64) +curl -L https://github.com/nutthead/samoyed/releases/latest/download/samoyed-linux-x86_64 -o samoyed +chmod +x samoyed +sudo mv samoyed /usr/local/bin/ + +# macOS (Intel) +curl -L https://github.com/nutthead/samoyed/releases/latest/download/samoyed-macos-x86_64 -o samoyed +chmod +x samoyed +sudo mv samoyed /usr/local/bin/ + +# macOS (Apple Silicon) +curl -L https://github.com/nutthead/samoyed/releases/latest/download/samoyed-macos-arm64 -o samoyed +chmod +x samoyed +sudo mv samoyed /usr/local/bin/ + +# Windows (PowerShell) +curl https://github.com/nutthead/samoyed/releases/latest/download/samoyed-windows-x86_64.exe -o samoyed.exe +# Move to a directory in your PATH +``` + +## From Source with Cargo + +**For Rust developers or latest features** + +```bash +# Install from crates.io +cargo install samoyed + +# Or build from source +git clone https://github.com/nutthead/samoyed.git +cd samoyed +cargo install --path . +``` + +### Build Requirements +- Rust 1.70 or later +- Git (for testing) + +## Via Package Managers + +### Homebrew (macOS/Linux) +```bash +# Coming soon +brew install samoyed +``` + +### Cargo-binstall (faster than building) +```bash +cargo binstall samoyed +``` + +## Verify Installation + +Check that Samoyed is installed correctly: + +```bash +samoyed --version +``` + +You should see output like: +``` +samoyed 0.2.3 +``` + +## Optional: Install Git LFS + +For Git LFS integration features, install Git LFS: + +```bash +# macOS +brew install git-lfs + +# Ubuntu/Debian +sudo apt-get install git-lfs + +# Fedora +sudo dnf install git-lfs + +# Windows (with Chocolatey) +choco install git-lfs + +# Windows (with Scoop) +scoop install git-lfs +``` + +Then initialize it: +```bash +git lfs install +``` + +## Next Steps + +Now that Samoyed is installed: + +- [Quick Start](./quick-start.md): Set up your first hooks +- [Basic Usage](./basic-usage.md): Learn the commands + +## Uninstallation + +### Binary Installation +```bash +sudo rm /usr/local/bin/samoyed +``` + +### Cargo Installation +```bash +cargo uninstall samoyed +``` + +### Repository Cleanup +To remove Samoyed from a specific repository: +```bash +# This removes the hooks directory and resets Git config +rm -rf .samoyed +git config --unset core.hooksPath +``` diff --git a/docs/src/introduction.md b/docs/src/introduction.md new file mode 100644 index 0000000..a3ad92e --- /dev/null +++ b/docs/src/introduction.md @@ -0,0 +1,69 @@ +# Introduction + +**Samoyed** is a minimal, cross-platform Git hooks manager written in Rust. It provides a simple, consistent interface for managing client-side Git hooks without the complexity and overhead of larger tools. + +## Why Samoyed? + +- **Single Binary**: No runtime dependencies, just one executable +- **Cross-Platform**: Works on Linux, macOS, and Windows +- **Minimal**: ~1200 lines of Rust code, focused on doing one thing well +- **Git LFS Support**: First-class integration with Git Large File Storage +- **Hook Composition**: Chain multiple hooks together with the hooks.d pattern +- **Zero Configuration**: Works out of the box with sensible defaults +- **Fast**: Rust performance with optimized release builds + +## Key Features + +### Core Functionality +- Initialize hooks in any Git repository +- Custom hook directory names +- POSIX-compliant wrapper scripts +- Bypass mode for emergency situations + +### Advanced Features +- **Git LFS Integration**: Automatic detection and integration of Git LFS commands +- **Hook Composition**: Run multiple hooks in sequence with the hooks.d pattern +- **Existing Hook Detection**: Import hooks from other tools seamlessly + +### Developer Experience +- Simple CLI interface +- Clear error messages +- Comprehensive test suite +- Well-documented codebase + +## Philosophy + +Samoyed follows these principles: + +1. **Simplicity**: Single-file Rust implementation, avoiding unnecessary complexity +2. **Reliability**: Comprehensive testing (26 unit tests, 15 integration tests) +3. **Minimalism**: No feature creep, focused scope +4. **Standards**: POSIX-compliant shell scripts, standard Git hooks + +## Project Status + +Samoyed is actively maintained and production-ready. It powers Git workflows for individual developers and teams. + +## Quick Example + +```bash +# Initialize hooks +samoyed init + +# Create a pre-commit hook +cat > .samoyed/pre-commit <<'EOF' +#!/usr/bin/env sh +cargo fmt --check +cargo clippy -- -D warnings +EOF +chmod +x .samoyed/pre-commit + +# Hooks run automatically +git commit -m "test" +``` + +## Next Steps + +- [Installation](./installation.md): Get Samoyed on your system +- [Quick Start](./quick-start.md): Your first hooks in 5 minutes +- [Basic Usage](./basic-usage.md): Learn the fundamentals diff --git a/docs/src/lfs-integration.md b/docs/src/lfs-integration.md new file mode 100644 index 0000000..bd0fa6f --- /dev/null +++ b/docs/src/lfs-integration.md @@ -0,0 +1,265 @@ +# Git LFS Integration + +Samoyed provides first-class support for Git Large File Storage (LFS), automatically integrating LFS commands into your hooks when needed. + +## Overview + +Git LFS is a Git extension for versioning large files. It replaces large files with text pointers inside Git, while storing the file contents on a remote server. + +Samoyed detects when your repository uses Git LFS and can automatically add the necessary LFS commands to your hooks, ensuring LFS operations run at the right time. + +## Automatic Detection + +By default, Samoyed auto-detects Git LFS: + +```bash +samoyed init +``` + +Samoyed checks: +1. Is `git-lfs` installed? (`git lfs version`) +2. Is LFS configured in this repo? (`git config filter.lfs.process`) + +If both are true, LFS integration is enabled automatically. + +###Manual Control + +Override auto-detection with flags: + +#### Force Enable LFS + +```bash +samoyed init --with-lfs +``` + +Use this when: +- You're about to set up LFS +- Auto-detection failed but you have LFS +- You want LFS integration regardless of current state + +#### Force Disable LFS + +```bash +samoyed init --no-lfs +``` + +Use this when: +- You don't use LFS +- You want faster initialization +- You have LFS but don't want Samoyed to manage it + +## Managing LFS + +After initialization, you can toggle LFS integration: + +### Check Status + +```bash +samoyed lfs status +``` + +Example output: +``` +Git LFS installation: ✓ +Repository LFS config: ✓ Configured +Samoyed hooks initialized: ✓ in .samoyed/_ +LFS integration: Enabled +``` + +### Enable LFS + +```bash +samoyed lfs enable +``` + +Adds LFS commands to existing hooks. Safe to run multiple times. + +### Disable LFS + +```bash +samoyed lfs disable +``` + +Removes LFS commands from hooks while preserving other hook logic. + +## How It Works + +When LFS integration is enabled, Samoyed adds LFS commands to specific hooks: + +### pre-push Hook + +```sh +#!/usr/bin/env sh +# SAMOYED_LFS_BEGIN +if command -v git-lfs >/dev/null 2>&1; then + hook_name="$(basename "$0")" + case "$hook_name" in + pre-push) + git lfs pre-push "$@" || exit $? + ;; + esac +fi +# SAMOYED_LFS_END + +# Your custom hook logic runs after +. "$(dirname "$0")/samoyed" +``` + +### post-checkout, post-commit, post-merge Hooks + +```sh +#!/usr/bin/env sh +# SAMOYED_LFS_BEGIN +if command -v git-lfs >/dev/null 2>&1; then + hook_name="$(basename "$0")" + case "$hook_name" in + post-checkout|post-commit|post-merge) + git lfs post-checkout "$@" + ;; + esac +fi +# SAMOYED_LFS_END + +# Your custom hook logic +. "$(dirname "$0")/samoyed" +``` + +## Execution Order + +LFS commands run **before** your custom hooks: + +1. LFS commands (if enabled) +2. hooks.d/ scripts (if using [hook composition](./hook-composition.md)) +3. Your custom hooks in `.samoyed/` + +This ensures LFS files are available when your hooks run. + +## Example Workflow + +### Setting Up a New Repository with LFS + +```bash +# Initialize Git and Samoyed +git init +samoyed init --with-lfs + +# Set up LFS for large files +git lfs install +git lfs track "*.psd" +git lfs track "*.zip" + +# Create a custom pre-push hook +cat > .samoyed/pre-push <<'EOF' +#!/usr/bin/env sh +echo "Running custom checks..." +cargo test +EOF +chmod +x .samoyed/pre-push + +# When you push, LFS runs first, then your tests +git add . +git commit -m "add large files" +git push # LFS uploads files, then tests run +``` + +### Adding LFS to Existing Hooks + +```bash +# You already have Samoyed set up +cd existing-project + +# Enable LFS integration +samoyed lfs enable + +# Verify +samoyed lfs status + +# Your existing hooks still work! +git commit -m "test" +``` + +### Removing LFS Integration + +```bash +# Remove LFS from hooks +samoyed lfs disable + +# Verify +samoyed lfs status +# Shows: LFS integration: Disabled + +# Your other hooks still work +``` + +## Compatibility + +### Works With +- Git LFS 2.0+ +- All platforms (Linux, macOS, Windows) +- [Hook composition](./hook-composition.md) mode +- Custom hook scripts + +### Doesn't Interfere With +- Manual `git lfs` commands +- LFS configuration in `.lfsconfig` +- LFS attributes in `.gitattributes` +- Other LFS tools + +## Troubleshooting + +### "git-lfs not found" Error + +Install Git LFS: +```bash +# macOS +brew install git-lfs + +# Ubuntu/Debian +apt-get install git-lfs + +# Initialize +git lfs install +``` + +### LFS Commands Not Running + +Check if LFS integration is enabled: +```bash +samoyed lfs status +``` + +If disabled, enable it: +```bash +samoyed lfs enable +``` + +### LFS Slowing Down Commits + +LFS operations in `post-checkout`, `post-commit`, and `post-merge` are non-blocking (don't fail the operation). Only `pre-push` can fail and prevent a push. + +To completely disable: +```bash +samoyed lfs disable +``` + +### Existing LFS Hooks Conflict + +If you have existing LFS hooks in `.git/hooks/`, Samoyed will: +1. Warn you about them +2. Suggest using `--hooks-d` to import them + +See [Hook Composition](./hook-composition.md) for details. + +## Best Practices + +1. **Enable LFS early**: Run `samoyed init --with-lfs` when setting up a new repository +2. **Check status first**: Run `samoyed lfs status` to see current state +3. **Use auto-detection**: Let Samoyed detect LFS automatically in most cases +4. **Combine with hooks.d**: Use [hook composition](./hook-composition.md) for complex workflows +5. **Test thoroughly**: Push to a test branch first when adding LFS to existing repos + +## Next Steps + +- [Hook Composition](./hook-composition.md): Chain multiple hooks together +- [Combined Features](./combined-features.md): Use LFS with hooks.d +- [Troubleshooting](./troubleshooting.md): Solve common issues diff --git a/docs/src/license.md b/docs/src/license.md new file mode 100644 index 0000000..104d11d --- /dev/null +++ b/docs/src/license.md @@ -0,0 +1,3 @@ +# License + +Documentation coming soon. diff --git a/docs/src/migration.md b/docs/src/migration.md new file mode 100644 index 0000000..28fcc6e --- /dev/null +++ b/docs/src/migration.md @@ -0,0 +1,3 @@ +# Migration + +Documentation coming soon. diff --git a/docs/src/quick-start.md b/docs/src/quick-start.md new file mode 100644 index 0000000..b35b835 --- /dev/null +++ b/docs/src/quick-start.md @@ -0,0 +1,168 @@ +# Quick Start + +Get started with Samoyed in just a few minutes. This guide walks you through setting up your first Git hook. + +## Prerequisites + +- Git repository (or create a new one) +- Samoyed [installed](./installation.md) + +## Step 1: Initialize Samoyed + +Navigate to your Git repository and run: + +```bash +cd your-project +samoyed init +``` + +This creates: +- `.samoyed/` - directory for your hook scripts +- `.samoyed/_/` - directory with generated hook wrappers +- `.samoyed/pre-commit` - sample pre-commit hook + +Samoyed automatically configures Git to use these hooks: +```bash +git config core.hooksPath .samoyed/_ +``` + +## Step 2: Create Your First Hook + +Let's create a simple pre-commit hook that checks for TODO comments: + +```bash +cat > .samoyed/pre-commit <<'EOF' +#!/usr/bin/env sh + +# Check for TODO comments +if git diff --cached | grep -E '^\+.*TODO'; then + echo "❌ Error: Found TODO comments in staged changes" + echo "Remove or address them before committing" + exit 1 +fi + +echo "✓ No TODO comments found" +EOF + +chmod +x .samoyed/pre-commit +``` + +## Step 3: Test It + +Try to commit a file with a TODO: + +```bash +echo "// TODO: fix this" > test.txt +git add test.txt +git commit -m "test commit" +``` + +The hook will prevent the commit: +``` +❌ Error: Found TODO comments in staged changes +Remove or address them before committing +``` + +Remove the TODO and try again: +```bash +echo "// Fixed!" > test.txt +git add test.txt +git commit -m "test commit" +``` + +Success! The commit goes through. + +## Step 4: Add More Hooks + +Create as many hooks as you need. Common examples: + +### Pre-commit: Run Tests + +```bash +cat > .samoyed/pre-commit <<'EOF' +#!/usr/bin/env sh +cargo test --quiet +EOF +chmod +x .samoyed/pre-commit +``` + +### Commit-msg: Validate Format + +```bash +cat > .samoyed/commit-msg <<'EOF' +#!/usr/bin/env sh +commit_msg=$(cat "$1") + +if ! echo "$commit_msg" | grep -qE '^(feat|fix|docs|chore|test|refactor):'; then + echo "❌ Commit message must start with a type: feat, fix, docs, chore, test, refactor" + echo "Example: feat: add new feature" + exit 1 +fi +EOF +chmod +x .samoyed/commit-msg +``` + +### Pre-push: Run CI Locally + +```bash +cat > .samoyed/pre-push <<'EOF' +#!/usr/bin/env sh +echo "Running tests before push..." +cargo test +cargo clippy -- -D warnings +EOF +chmod +x .samoyed/pre-push +``` + +## Common Commands + +```bash +# Initialize hooks +samoyed init + +# Initialize with custom directory +samoyed init my-hooks + +# Check Git LFS status +samoyed lfs status + +# Enable Git LFS integration +samoyed lfs enable + +# Bypass hooks temporarily +SAMOYED=0 git commit -m "emergency fix" + +# Debug hook execution +SAMOYED=2 git commit -m "test" +``` + +## Next Steps + +- [Basic Usage](./basic-usage.md): Learn all the features +- [Git LFS Integration](./lfs-integration.md): Work with large files +- [Hook Composition](./hook-composition.md): Chain multiple hooks +- [Common Use Cases](./use-cases.md): Real-world examples + +## Troubleshooting + +### Hooks not running? + +Check that Git is configured correctly: +```bash +git config core.hooksPath +# Should show: .samoyed/_ +``` + +### Need to skip hooks once? + +Use the bypass mode: +```bash +SAMOYED=0 git commit -m "skip hooks" +``` + +### Want to see what's happening? + +Enable debug mode: +```bash +SAMOYED=2 git commit -m "debug" +``` diff --git a/docs/src/testing.md b/docs/src/testing.md new file mode 100644 index 0000000..0b1c46a --- /dev/null +++ b/docs/src/testing.md @@ -0,0 +1,3 @@ +# Testing + +Documentation coming soon. diff --git a/docs/src/troubleshooting.md b/docs/src/troubleshooting.md new file mode 100644 index 0000000..bb285c9 --- /dev/null +++ b/docs/src/troubleshooting.md @@ -0,0 +1,3 @@ +# Troubleshooting + +Documentation coming soon. diff --git a/docs/src/upgrading.md b/docs/src/upgrading.md new file mode 100644 index 0000000..01e8744 --- /dev/null +++ b/docs/src/upgrading.md @@ -0,0 +1,3 @@ +# Upgrading + +Documentation coming soon. diff --git a/docs/src/use-cases.md b/docs/src/use-cases.md new file mode 100644 index 0000000..5425d07 --- /dev/null +++ b/docs/src/use-cases.md @@ -0,0 +1,3 @@ +# Use Cases + +Documentation coming soon. diff --git a/src/main.rs b/src/main.rs index b4d800b..eac17b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,9 @@ const DEFAULT_SAMOYED_DIR: &str = ".samoyed"; /// Directory name for wrapper scripts within the Samoyed directory. const WRAPPER_DIR_NAME: &str = "_"; +/// Directory name for hook composition (hooks.d pattern). +const HOOKS_D_DIR_NAME: &str = "hooks.d"; + /// Filename for the embedded wrapper script within the wrapper directory. const WRAPPER_SCRIPT_NAME: &str = "samoyed"; @@ -145,6 +148,84 @@ const HOOK_SCRIPT_TEMPLATE: &str = r#"#!/usr/bin/env sh . "$(dirname "$0")/samoyed" "#; +/// Shell script template for Git hooks with LFS integration. +/// +/// This template includes calls to git-lfs commands before running user hooks. +/// The LFS commands are conditionally executed only if git-lfs is available. +const HOOK_SCRIPT_TEMPLATE_WITH_LFS: &str = r#"#!/usr/bin/env sh +# SAMOYED_LFS_BEGIN - Auto-managed Git LFS integration +if command -v git-lfs >/dev/null 2>&1; then + hook_name="$(basename "$0")" + case "$hook_name" in + pre-push) + git lfs pre-push "$@" || exit $? + ;; + post-checkout|post-commit|post-merge) + git lfs post-checkout "$@" + ;; + esac +fi +# SAMOYED_LFS_END +. "$(dirname "$0")/samoyed" +"#; + +/// Shell script template for Git hooks in composition mode (hooks.d runner). +/// +/// This template runs all executable scripts in the corresponding hooks.d subdirectory +/// in lexicographic order, allowing multiple hooks to be chained together. +const HOOK_SCRIPT_TEMPLATE_HOOKS_D: &str = r#"#!/usr/bin/env sh +# SAMOYED_HOOKS_D - Hook composition mode +hook_name="$(basename "$0")" +hooks_d_dir="$(dirname "$0")/../hooks.d" + +# Run all hooks for this hook type in order +if [ -d "$hooks_d_dir" ]; then + for hook_script in "$hooks_d_dir"/*."$hook_name"; do + if [ -x "$hook_script" ]; then + "$hook_script" "$@" || exit $? + fi + done +fi + +# Also run user hook if it exists +. "$(dirname "$0")/samoyed" +"#; + +/// Shell script template for Git hooks in composition mode with LFS integration. +/// +/// Combines both LFS calls and hooks.d pattern execution. +const HOOK_SCRIPT_TEMPLATE_HOOKS_D_WITH_LFS: &str = r#"#!/usr/bin/env sh +# SAMOYED_LFS_BEGIN - Auto-managed Git LFS integration +if command -v git-lfs >/dev/null 2>&1; then + hook_name="$(basename "$0")" + case "$hook_name" in + pre-push) + git lfs pre-push "$@" || exit $? + ;; + post-checkout|post-commit|post-merge) + git lfs post-checkout "$@" + ;; + esac +fi +# SAMOYED_LFS_END + +# SAMOYED_HOOKS_D - Hook composition mode +hook_name="$(basename "$0")" +hooks_d_dir="$(dirname "$0")/../hooks.d" + +# Run all hooks for this hook type in order +if [ -d "$hooks_d_dir" ]; then + for hook_script in "$hooks_d_dir"/*."$hook_name"; do + if [ -x "$hook_script" ]; then + "$hook_script" "$@" || exit $? + fi + done +fi + +# Also run user hook if it exists +. "$(dirname "$0")/samoyed" +"#; + /// Sample pre-commit hook template with placeholder comments for user customization. const SAMPLE_PRE_COMMIT_CONTENT: &str = r#"#!/usr/bin/env sh # Add your pre-commit checks here. For example: @@ -155,6 +236,44 @@ const SAMPLE_PRE_COMMIT_CONTENT: &str = r#"#!/usr/bin/env sh /// Gitignore pattern that excludes all files in the wrapper directory. const GITIGNORE_CONTENT: &str = "*\n"; +/// Message displayed when Git LFS is auto-detected and enabled. +const MSG_LFS_AUTO_ENABLED: &str = "Git LFS detected - enabling LFS integration"; + +/// Message displayed when Git LFS integration is explicitly enabled. +const MSG_LFS_ENABLED: &str = "Git LFS integration enabled"; + +/// Message displayed when Git LFS integration is explicitly disabled. +const MSG_LFS_DISABLED: &str = "Git LFS integration disabled"; + +/// Error message when git-lfs command is not found. +const ERR_LFS_NOT_INSTALLED: &str = "Error: git-lfs is not installed or not in PATH"; + +/// Error message when Samoyed is not initialized in the repository. +const ERR_SAMOYED_NOT_INITIALIZED: &str = + "Error: Samoyed is not initialized. Run 'samoyed init' first"; + +/// Warning message when existing hooks are detected. +const WARN_EXISTING_HOOKS: &str = "Warning: Existing Git hooks detected"; + +/// Info message about hook composition mode. +const INFO_HOOKS_D_MODE: &str = "Using hook composition mode (hooks.d/)"; + +/// Info message about importing existing hooks. +const INFO_IMPORTING_HOOKS: &str = "Importing existing hooks to hooks.d/"; + +/// LFS mode determining whether to enable Git LFS integration. +/// +/// This enum controls how Samoyed handles Git LFS integration during initialization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LfsMode { + /// Auto-detect LFS based on repository configuration + Auto, + /// Force enable LFS integration regardless of detection + ForceEnable, + /// Force disable LFS integration regardless of detection + ForceDisable, +} + /// Command-line interface for Samoyed. /// /// Samoyed is a modern, minimal, safe, ultra-fast, cross-platform Git hooks manager @@ -178,7 +297,35 @@ enum Commands { /// Directory name for Samoyed hooks (default: .samoyed) #[arg(value_name = "samoyed-dirname")] dirname: Option, + + /// Enable Git LFS integration (overrides auto-detection) + #[arg(long, conflicts_with = "no_lfs")] + with_lfs: bool, + + /// Disable Git LFS integration (overrides auto-detection) + #[arg(long, conflicts_with = "with_lfs")] + no_lfs: bool, + + /// Enable hook composition mode (hooks.d pattern for chaining multiple hooks) + #[arg(long)] + hooks_d: bool, }, + /// Manage Git LFS integration + Lfs { + #[command(subcommand)] + command: LfsCommands, + }, +} + +/// Subcommands for managing Git LFS integration +#[derive(Subcommand)] +enum LfsCommands { + /// Enable Git LFS integration in existing Samoyed setup + Enable, + /// Disable Git LFS integration in existing Samoyed setup + Disable, + /// Show Git LFS integration status + Status, } /// Main entry point for Samoyed @@ -187,9 +334,15 @@ enum Commands { /// If no command is provided, displays the help message and returns a success exit code. fn main() -> ExitCode { match Cli::parse().command { - Some(Commands::Init { dirname }) => { + Some(Commands::Init { + dirname, + with_lfs, + no_lfs, + hooks_d, + }) => { let dirname = dirname.unwrap_or_else(|| DEFAULT_SAMOYED_DIR.to_string()); - init_samoyed(&dirname).map_or_else( + let lfs_mode = determine_lfs_mode(with_lfs, no_lfs); + init_samoyed(&dirname, lfs_mode, hooks_d).map_or_else( |err| { eprintln!("{err}"); ExitCode::FAILURE @@ -197,31 +350,175 @@ fn main() -> ExitCode { |_| ExitCode::SUCCESS, ) } + Some(Commands::Lfs { command }) => match command { + LfsCommands::Enable => lfs_enable().map_or_else( + |err| { + eprintln!("{err}"); + ExitCode::FAILURE + }, + |_| ExitCode::SUCCESS, + ), + LfsCommands::Disable => lfs_disable().map_or_else( + |err| { + eprintln!("{err}"); + ExitCode::FAILURE + }, + |_| ExitCode::SUCCESS, + ), + LfsCommands::Status => { + lfs_status(); + ExitCode::SUCCESS + } + }, None => ExitCode::SUCCESS, } } +/// Determine the LFS mode based on command-line flags. +/// +/// This function implements the hybrid approach where explicit flags override auto-detection. +/// +/// # Arguments +/// +/// * `with_lfs` - Whether --with-lfs flag was provided +/// * `no_lfs` - Whether --no-lfs flag was provided +/// +/// # Returns +/// +/// Returns the appropriate LfsMode based on the flags +fn determine_lfs_mode(with_lfs: bool, no_lfs: bool) -> LfsMode { + if with_lfs { + LfsMode::ForceEnable + } else if no_lfs { + LfsMode::ForceDisable + } else { + LfsMode::Auto + } +} + +/// Detect if Git LFS is configured in the current repository. +/// +/// This function uses git commands exclusively (no file parsing) to determine if LFS is set up: +/// 1. Checks if git-lfs is installed and available in PATH +/// 2. Checks if LFS filters are configured in git config +/// +/// # Returns +/// +/// Returns true if LFS is detected and configured, false otherwise +fn detect_lfs_in_repo() -> bool { + // Check if git-lfs is installed + let lfs_installed = Command::new("git") + .args(["lfs", "version"]) + .output() + .is_ok_and(|o| o.status.success()); + + if !lfs_installed { + return false; + } + + // Check if LFS filter is configured in the repository + Command::new("git") + .args(["config", "--get", "filter.lfs.process"]) + .output() + .is_ok_and(|o| o.status.success()) +} + +/// Determine if LFS should be enabled based on the LFS mode. +/// +/// # Arguments +/// +/// * `mode` - The LFS mode (Auto, ForceEnable, or ForceDisable) +/// +/// # Returns +/// +/// Returns true if LFS should be enabled, false otherwise +fn should_enable_lfs(mode: LfsMode) -> bool { + match mode { + LfsMode::ForceEnable => true, + LfsMode::ForceDisable => false, + LfsMode::Auto => detect_lfs_in_repo(), + } +} + +/// Get the currently configured Git hooks path. +/// +/// This function checks `git config core.hooksPath` to see if a custom hooks path +/// is already configured. Returns None if not set (uses default .git/hooks). +/// +/// # Returns +/// +/// Returns Some(PathBuf) with the configured path, or None if using default +fn get_existing_hooks_path() -> Option { + let output = Command::new("git") + .args(["config", "--get", "core.hooksPath"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let path_str = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if path_str.is_empty() { + None + } else { + Some(PathBuf::from(path_str)) + } +} + +/// Detect existing hook files in the given directory. +/// +/// Scans the directory for executable files matching standard Git hook names. +/// +/// # Arguments +/// +/// * `hooks_dir` - Path to the directory to scan for hooks +/// +/// # Returns +/// +/// Returns a vector of hook names (without directory path) that exist +fn detect_existing_hooks(hooks_dir: &Path) -> Vec { + let mut found_hooks = Vec::new(); + + if !hooks_dir.exists() { + return found_hooks; + } + + for hook_name in GIT_HOOKS { + let hook_path = hooks_dir.join(hook_name); + if hook_path.exists() { + found_hooks.push((*hook_name).to_string()); + } + } + + found_hooks +} + /// Initialize Samoyed in the current git repository /// /// This function performs the following steps: /// 1. Checks if SAMOYED=0 (bypass mode) /// 2. Verifies we're inside a git repository -/// 3. Validates the samoyed directory path -/// 4. Creates the directory structure -/// 5. Copies the wrapper script -/// 6. Creates hook scripts -/// 7. Creates sample pre-commit hook -/// 8. Sets git config core.hooksPath -/// 9. Creates .gitignore in the _ directory +/// 3. Detects existing hooks and warns user +/// 4. Validates the samoyed directory path +/// 5. Creates the directory structure +/// 6. Copies the wrapper script +/// 7. Creates hook scripts (with or without LFS integration, with or without composition) +/// 8. Imports existing hooks if in composition mode +/// 9. Creates sample pre-commit hook +/// 10. Sets git config core.hooksPath +/// 11. Creates .gitignore in the _ directory /// /// # Arguments /// /// * `dirname` - The directory name for Samoyed hooks +/// * `lfs_mode` - The LFS mode determining whether to enable LFS integration +/// * `hooks_d` - Whether to use hook composition mode (hooks.d pattern) /// /// # Returns /// /// Returns Ok(()) on success, or an error message on failure -fn init_samoyed(dirname: &str) -> Result<(), String> { +fn init_samoyed(dirname: &str, lfs_mode: LfsMode, hooks_d: bool) -> Result<(), String> { // Check for bypass mode if check_bypass_mode() { println!("{}", MSG_BYPASS_INIT); @@ -233,17 +530,67 @@ fn init_samoyed(dirname: &str) -> Result<(), String> { let current_dir = env::current_dir().map_err(|e| format!("{}: {}", ERR_FAILED_CURRENT_DIR, e))?; + // Detect existing hooks before we change anything + let existing_hooks_path = get_existing_hooks_path() + .map(|p| { + if p.is_absolute() { + p + } else { + git_root.join(&p) + } + }) + .unwrap_or_else(|| git_root.join(".git/hooks")); + + let existing_hooks = detect_existing_hooks(&existing_hooks_path); + + // Warn if existing hooks found + if !existing_hooks.is_empty() { + eprintln!("{}", WARN_EXISTING_HOOKS); + eprintln!( + " Found {} hook(s) in: {}", + existing_hooks.len(), + existing_hooks_path.display() + ); + for hook in &existing_hooks { + eprintln!(" - {}", hook); + } + if hooks_d { + eprintln!(" These hooks will be imported to hooks.d/"); + } else { + eprintln!(" These hooks will become inactive. Use --hooks-d to import them."); + } + } + // Validate and resolve the samoyed directory path let samoyed_dir = validate_samoyed_dir(&git_root, ¤t_dir, dirname)?; + // Determine if LFS should be enabled + let enable_lfs = should_enable_lfs(lfs_mode); + + // Display LFS status message if auto-detected + if enable_lfs && lfs_mode == LfsMode::Auto { + println!("{}", MSG_LFS_AUTO_ENABLED); + } + + // Display hooks.d mode message + if hooks_d { + println!("{}", INFO_HOOKS_D_MODE); + } + // Create directory structure - create_directory_structure(&samoyed_dir)?; + create_directory_structure(&samoyed_dir, hooks_d)?; // Copy wrapper script to _/samoyed copy_wrapper_script(&samoyed_dir)?; - // Create hook scripts in _ directory - create_hook_scripts(&samoyed_dir)?; + // Create hook scripts in _ directory (with or without LFS integration, with or without composition) + create_hook_scripts(&samoyed_dir, enable_lfs, hooks_d)?; + + // Import existing hooks if in composition mode + if hooks_d && !existing_hooks.is_empty() { + println!("{}", INFO_IMPORTING_HOOKS); + import_existing_hooks(&samoyed_dir, &existing_hooks_path, &existing_hooks)?; + } // Create sample pre-commit hook create_sample_pre_commit(&samoyed_dir)?; @@ -421,16 +768,17 @@ fn canonicalize_allowing_nonexistent(path: &Path) -> std::io::Result { /// Create the directory structure for Samoyed /// -/// Creates the main samoyed directory and the _ subdirectory. +/// Creates the main samoyed directory, the _ subdirectory, and optionally hooks.d. /// /// # Arguments /// /// * `samoyed_dir` - Path to the samoyed directory +/// * `hooks_d` - Whether to create the hooks.d directory for composition mode /// /// # Returns /// /// Returns Ok(()) on success, or an error message on failure -fn create_directory_structure(samoyed_dir: &Path) -> Result<(), String> { +fn create_directory_structure(samoyed_dir: &Path, hooks_d: bool) -> Result<(), String> { // Create main samoyed directory fs::create_dir_all(samoyed_dir) .map_err(|e| format!("{}: {}", ERR_FAILED_CREATE_SAMOYED_DIR, e))?; @@ -440,6 +788,13 @@ fn create_directory_structure(samoyed_dir: &Path) -> Result<(), String> { fs::create_dir_all(&underscore_dir) .map_err(|e| format!("{}: {}", ERR_FAILED_CREATE_WRAPPER_DIR, e))?; + // Create hooks.d subdirectory if in composition mode + if hooks_d { + let hooks_d_dir = samoyed_dir.join(HOOKS_D_DIR_NAME); + fs::create_dir_all(&hooks_d_dir) + .map_err(|e| format!("Error: Failed to create hooks.d directory: {}", e))?; + } + Ok(()) } @@ -490,22 +845,34 @@ fn copy_wrapper_script(samoyed_dir: &Path) -> Result<(), String> { /// - Windows: Default filesystem permissions (executable attribute handled automatically) /// /// Each script sources the shared wrapper so user hooks run consistently. +/// If LFS is enabled, hooks include Git LFS integration commands. +/// If hooks_d is enabled, hooks run all scripts in hooks.d/ directory. /// /// # Arguments /// /// * `samoyed_dir` - Path to the samoyed directory +/// * `enable_lfs` - Whether to include Git LFS integration in hooks +/// * `hooks_d` - Whether to use hook composition mode (hooks.d pattern) /// /// # Returns /// /// Returns Ok(()) on success, or an error message on failure -fn create_hook_scripts(samoyed_dir: &Path) -> Result<(), String> { +fn create_hook_scripts(samoyed_dir: &Path, enable_lfs: bool, hooks_d: bool) -> Result<(), String> { let underscore_dir = samoyed_dir.join(WRAPPER_DIR_NAME); + // Choose the appropriate template based on LFS and hooks_d settings + let template = match (enable_lfs, hooks_d) { + (true, true) => HOOK_SCRIPT_TEMPLATE_HOOKS_D_WITH_LFS, + (true, false) => HOOK_SCRIPT_TEMPLATE_WITH_LFS, + (false, true) => HOOK_SCRIPT_TEMPLATE_HOOKS_D, + (false, false) => HOOK_SCRIPT_TEMPLATE, + }; + for hook_name in GIT_HOOKS { let hook_path = underscore_dir.join(hook_name); // Write the hook script - fs::write(&hook_path, HOOK_SCRIPT_TEMPLATE) + fs::write(&hook_path, template) .map_err(|e| format!("{} '{}': {}", ERR_FAILED_WRITE_HOOK, hook_name, e))?; // Set permissions to 755 (rwxr-xr-x) @@ -523,6 +890,53 @@ fn create_hook_scripts(samoyed_dir: &Path) -> Result<(), String> { Ok(()) } +/// Import existing hooks from the old hooks directory to hooks.d/ +/// +/// Copies existing hook files to the hooks.d directory with a naming pattern +/// that ensures they run before user-defined hooks (50- prefix). +/// +/// # Arguments +/// +/// * `samoyed_dir` - Path to the samoyed directory +/// * `old_hooks_path` - Path to the directory containing existing hooks +/// * `hook_names` - List of hook names to import +/// +/// # Returns +/// +/// Returns Ok(()) on success, or an error message on failure +fn import_existing_hooks( + samoyed_dir: &Path, + old_hooks_path: &Path, + hook_names: &[String], +) -> Result<(), String> { + let hooks_d_dir = samoyed_dir.join(HOOKS_D_DIR_NAME); + + for hook_name in hook_names { + let source_path = old_hooks_path.join(hook_name); + // Use 50- prefix so imported hooks run after LFS (00-) but before custom (99-) + let dest_path = hooks_d_dir.join(format!("50-imported.{}", hook_name)); + + // Copy the hook file + fs::copy(&source_path, &dest_path) + .map_err(|e| format!("Error: Failed to import hook '{}': {}", hook_name, e))?; + + // Set executable permissions on Unix + #[cfg(unix)] + { + let metadata = fs::metadata(&dest_path) + .map_err(|e| format!("{}: {}", ERR_FAILED_GET_METADATA, e))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&dest_path, permissions) + .map_err(|e| format!("{}: {}", ERR_FAILED_SET_PERMISSIONS, e))?; + } + + eprintln!(" Imported: {} -> {}", hook_name, dest_path.display()); + } + + Ok(()) +} + /// Create a sample pre-commit hook in the samoyed directory /// /// This creates a simple pre-commit hook template that users can extend. @@ -631,6 +1045,172 @@ fn create_gitignore(samoyed_dir: &Path) -> Result<(), String> { Ok(()) } +/// Detect if hooks.d mode is currently enabled. +/// +/// Checks if the pre-push hook contains the SAMOYED_HOOKS_D marker. +/// +/// # Arguments +/// +/// * `samoyed_dir` - Path to the samoyed directory +/// +/// # Returns +/// +/// Returns true if hooks.d mode is enabled, false otherwise +fn is_hooks_d_enabled(samoyed_dir: &Path) -> bool { + let pre_push_hook = samoyed_dir.join(WRAPPER_DIR_NAME).join("pre-push"); + if let Ok(content) = fs::read_to_string(&pre_push_hook) { + content.contains("SAMOYED_HOOKS_D") + } else { + false + } +} + +/// Enable Git LFS integration in an existing Samoyed setup. +/// +/// This function regenerates hook scripts with LFS integration enabled. +/// It requires Samoyed to already be initialized in the repository. +/// Preserves hooks.d mode if it was enabled. +/// +/// # Returns +/// +/// Returns Ok(()) on success, or an error message on failure +fn lfs_enable() -> Result<(), String> { + // Check if git-lfs is installed + let lfs_installed = Command::new("git") + .args(["lfs", "version"]) + .output() + .is_ok_and(|o| o.status.success()); + + if !lfs_installed { + return Err(ERR_LFS_NOT_INSTALLED.to_string()); + } + + // Get git root and find samoyed directory + let git_root = get_git_root()?; + let samoyed_dir = git_root.join(DEFAULT_SAMOYED_DIR); + + // Check if Samoyed is initialized + if !samoyed_dir.exists() || !samoyed_dir.join(WRAPPER_DIR_NAME).exists() { + return Err(ERR_SAMOYED_NOT_INITIALIZED.to_string()); + } + + // Detect if hooks.d mode is enabled + let hooks_d = is_hooks_d_enabled(&samoyed_dir); + + // Regenerate hook scripts with LFS enabled + create_hook_scripts(&samoyed_dir, true, hooks_d)?; + + println!("{}", MSG_LFS_ENABLED); + Ok(()) +} + +/// Disable Git LFS integration in an existing Samoyed setup. +/// +/// This function regenerates hook scripts without LFS integration. +/// It requires Samoyed to already be initialized in the repository. +/// Preserves hooks.d mode if it was enabled. +/// +/// # Returns +/// +/// Returns Ok(()) on success, or an error message on failure +fn lfs_disable() -> Result<(), String> { + // Get git root and find samoyed directory + let git_root = get_git_root()?; + let samoyed_dir = git_root.join(DEFAULT_SAMOYED_DIR); + + // Check if Samoyed is initialized + if !samoyed_dir.exists() || !samoyed_dir.join(WRAPPER_DIR_NAME).exists() { + return Err(ERR_SAMOYED_NOT_INITIALIZED.to_string()); + } + + // Detect if hooks.d mode is enabled + let hooks_d = is_hooks_d_enabled(&samoyed_dir); + + // Regenerate hook scripts without LFS + create_hook_scripts(&samoyed_dir, false, hooks_d)?; + + println!("{}", MSG_LFS_DISABLED); + Ok(()) +} + +/// Show the current Git LFS integration status. +/// +/// This function displays: +/// - Whether git-lfs is installed +/// - Whether LFS is configured in the repository +/// - Whether Samoyed hooks have LFS integration enabled +fn lfs_status() { + // Check if git-lfs is installed + let lfs_installed = Command::new("git") + .args(["lfs", "version"]) + .output() + .is_ok_and(|o| o.status.success()); + + println!( + "Git LFS installation: {}", + if lfs_installed { "✓" } else { "✗" } + ); + + if !lfs_installed { + println!(" Install git-lfs to use LFS integration"); + return; + } + + // Check if LFS is configured in the repo + let lfs_configured = Command::new("git") + .args(["config", "--get", "filter.lfs.process"]) + .output() + .is_ok_and(|o| o.status.success()); + + println!( + "LFS configured in repo: {}", + if lfs_configured { "✓" } else { "✗" } + ); + + // Check if Samoyed is initialized + let git_root = match get_git_root() { + Ok(root) => root, + Err(_) => { + println!("Samoyed status: Not in a git repository"); + return; + } + }; + + let samoyed_dir = git_root.join(DEFAULT_SAMOYED_DIR); + let hooks_dir = samoyed_dir.join(WRAPPER_DIR_NAME); + + if !hooks_dir.exists() { + println!("Samoyed status: Not initialized"); + return; + } + + // Check if hooks have LFS integration and hooks.d mode by examining a hook file + let pre_push_hook = hooks_dir.join("pre-push"); + if let Ok(content) = fs::read_to_string(&pre_push_hook) { + let has_lfs = content.contains("SAMOYED_LFS_BEGIN"); + println!( + "Samoyed LFS integration: {}", + if has_lfs { + "✓ Enabled" + } else { + "✗ Disabled" + } + ); + + let has_hooks_d = content.contains("SAMOYED_HOOKS_D"); + println!( + "Hook composition mode: {}", + if has_hooks_d { + "✓ Enabled (hooks.d/)" + } else { + "✗ Disabled" + } + ); + } else { + println!("Samoyed status: Hook files not readable"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -715,7 +1295,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let samoyed_dir = temp_dir.path().join(".samoyed"); - let result = create_directory_structure(&samoyed_dir); + let result = create_directory_structure(&samoyed_dir, false); assert!(result.is_ok()); // Check that directories were created @@ -723,7 +1303,7 @@ mod tests { assert!(samoyed_dir.join("_").exists()); // Test idempotency - should work even if directories exist - let result = create_directory_structure(&samoyed_dir); + let result = create_directory_structure(&samoyed_dir, false); assert!(result.is_ok()); } @@ -759,7 +1339,7 @@ mod tests { let samoyed_dir = temp_dir.path().join(".samoyed"); fs::create_dir_all(samoyed_dir.join("_")).unwrap(); - let result = create_hook_scripts(&samoyed_dir); + let result = create_hook_scripts(&samoyed_dir, false, false); assert!(result.is_ok()); // Check that all hook scripts were created @@ -861,7 +1441,7 @@ mod tests { // Test parsing init command let cli = Cli::parse_from(["samoyed", "init"]); match cli.command { - Some(Commands::Init { dirname }) => { + Some(Commands::Init { dirname, .. }) => { assert!(dirname.is_none()); } _ => panic!("Expected Init command"), @@ -870,7 +1450,7 @@ mod tests { // Test parsing init command with dirname let cli = Cli::parse_from(["samoyed", "init", ".hooks"]); match cli.command { - Some(Commands::Init { dirname }) => { + Some(Commands::Init { dirname, .. }) => { assert_eq!(dirname, Some(".hooks".to_string())); } _ => panic!("Expected Init command"), @@ -900,7 +1480,7 @@ mod tests { env::set_var("SAMOYED", "0"); } - let result = init_samoyed(".samoyed"); + let result = init_samoyed(".samoyed", LfsMode::ForceDisable, false); assert!(result.is_ok()); unsafe { @@ -915,7 +1495,7 @@ mod tests { let original_dir = env::current_dir().unwrap(); env::set_current_dir(temp_dir.path()).unwrap(); - let result = init_samoyed(".samoyed"); + let result = init_samoyed(".samoyed", LfsMode::ForceDisable, false); assert!(result.is_err()); let err_msg = result.unwrap_err(); assert!(err_msg.contains("Not a git repository")); @@ -1011,7 +1591,7 @@ mod tests { }); // Run init - let result = init_samoyed(".samoyed"); + let result = init_samoyed(".samoyed", LfsMode::ForceDisable, false); assert!(result.is_ok()); // Verify directory structure @@ -1077,7 +1657,7 @@ mod tests { }); // Run init with custom directory - let result = init_samoyed(".hooks"); + let result = init_samoyed(".hooks", LfsMode::ForceDisable, false); assert!(result.is_ok()); // Verify custom directory was created @@ -1107,11 +1687,11 @@ mod tests { }); // Run init first time - let result1 = init_samoyed(".samoyed"); + let result1 = init_samoyed(".samoyed", LfsMode::ForceDisable, false); assert!(result1.is_ok()); // Run init second time - let result2 = init_samoyed(".samoyed"); + let result2 = init_samoyed(".samoyed", LfsMode::ForceDisable, false); assert!(result2.is_ok()); // Verify structure still exists @@ -1347,4 +1927,126 @@ mod tests { env::set_current_dir(original_dir).unwrap(); } + + /// Test LFS mode determination + #[test] + fn test_determine_lfs_mode() { + assert_eq!(determine_lfs_mode(true, false), LfsMode::ForceEnable); + assert_eq!(determine_lfs_mode(false, true), LfsMode::ForceDisable); + assert_eq!(determine_lfs_mode(false, false), LfsMode::Auto); + } + + /// Test should_enable_lfs function + #[test] + fn test_should_enable_lfs() { + // ForceEnable should always return true + assert!(should_enable_lfs(LfsMode::ForceEnable)); + + // ForceDisable should always return false + assert!(!should_enable_lfs(LfsMode::ForceDisable)); + + // Auto mode depends on repository configuration (may be true or false) + // We just verify it doesn't panic + let _auto_result = should_enable_lfs(LfsMode::Auto); + } + + /// Test create_hook_scripts with LFS enabled + #[test] + fn test_create_hook_scripts_with_lfs() { + let temp_dir = TempDir::new().unwrap(); + let samoyed_dir = temp_dir.path().join(".samoyed"); + fs::create_dir_all(samoyed_dir.join("_")).unwrap(); + + let result = create_hook_scripts(&samoyed_dir, true, false); + assert!(result.is_ok()); + + // Check that hook scripts contain LFS integration + let pre_push_hook = samoyed_dir.join("_").join("pre-push"); + let content = fs::read_to_string(&pre_push_hook).unwrap(); + assert!(content.contains("SAMOYED_LFS_BEGIN")); + assert!(content.contains("git lfs pre-push")); + } + + /// Test create_hook_scripts without LFS + #[test] + fn test_create_hook_scripts_without_lfs() { + let temp_dir = TempDir::new().unwrap(); + let samoyed_dir = temp_dir.path().join(".samoyed"); + fs::create_dir_all(samoyed_dir.join("_")).unwrap(); + + let result = create_hook_scripts(&samoyed_dir, false, false); + assert!(result.is_ok()); + + // Check that hook scripts do NOT contain LFS integration + let pre_push_hook = samoyed_dir.join("_").join("pre-push"); + let content = fs::read_to_string(&pre_push_hook).unwrap(); + assert!(!content.contains("SAMOYED_LFS_BEGIN")); + assert!(!content.contains("git lfs")); + } + + /// Test init_samoyed with LFS force enable + #[test] + fn test_init_samoyed_with_lfs_force_enable() { + let git_repo = create_test_git_repo(); + let original_dir = env::current_dir().unwrap(); + env::set_current_dir(git_repo.path()).unwrap(); + + // Run init with LFS force enabled + let result = init_samoyed(".samoyed", LfsMode::ForceEnable, false); + assert!(result.is_ok()); + + // Verify LFS integration in hooks + let samoyed_dir = git_repo.path().join(".samoyed"); + let pre_push_hook = samoyed_dir.join("_").join("pre-push"); + let content = fs::read_to_string(&pre_push_hook).unwrap(); + assert!(content.contains("SAMOYED_LFS_BEGIN")); + + env::set_current_dir(original_dir).unwrap(); + } + + /// Test CLI parsing with LFS flags + #[test] + fn test_cli_parsing_with_lfs_flags() { + // Test parsing init with --with-lfs + let cli = Cli::parse_from(["samoyed", "init", "--with-lfs"]); + match cli.command { + Some(Commands::Init { + dirname, + with_lfs, + no_lfs, + .. + }) => { + assert!(dirname.is_none()); + assert!(with_lfs); + assert!(!no_lfs); + } + _ => panic!("Expected Init command"), + } + + // Test parsing init with --no-lfs + let cli = Cli::parse_from(["samoyed", "init", "--no-lfs"]); + match cli.command { + Some(Commands::Init { + dirname, + with_lfs, + no_lfs, + .. + }) => { + assert!(dirname.is_none()); + assert!(!with_lfs); + assert!(no_lfs); + } + _ => panic!("Expected Init command"), + } + + // Test parsing lfs status subcommand + let cli = Cli::parse_from(["samoyed", "lfs", "status"]); + match cli.command { + Some(Commands::Lfs { command }) => match command { + LfsCommands::Status => {} + _ => panic!("Expected Status command"), + }, + _ => panic!("Expected Lfs command"), + } + } } diff --git a/tests/integration/11_lfs_flags.sh b/tests/integration/11_lfs_flags.sh new file mode 100755 index 0000000..fafc55f --- /dev/null +++ b/tests/integration/11_lfs_flags.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env sh +# Test: Git LFS integration with --with-lfs and --no-lfs flags + +integration_script_dir="$(cd "$(dirname "$0")" && pwd)" +integration_repo_root="$(cd "$integration_script_dir/../.." && pwd)" +cd "$integration_repo_root" +. "$integration_repo_root/tests/integration/functions.sh" + +parse_common_args "$@" +build_samoyed +setup + +echo "Test: Git LFS integration flags" + +# Test 1: Force enable LFS with --with-lfs +echo " 1. Testing --with-lfs flag..." +init_samoyed --with-lfs + +# Verify LFS markers in hook scripts +pre_push_hook=".samoyed/_/pre-push" +if [ ! -f "$pre_push_hook" ]; then + error "pre-push hook not created" +fi + +if ! grep -q "SAMOYED_LFS_BEGIN" "$pre_push_hook"; then + error "LFS integration not found in hooks (--with-lfs)" +fi + +if ! grep -q "git lfs pre-push" "$pre_push_hook"; then + error "git lfs pre-push command not found in pre-push hook" +fi + +ok "LFS integration enabled with --with-lfs" + +# Test 2: Verify post-checkout hook has LFS +post_checkout_hook=".samoyed/_/post-checkout" +if ! grep -q "git lfs post-checkout" "$post_checkout_hook"; then + error "git lfs post-checkout command not found" +fi + +ok "LFS post-checkout hook configured correctly" + +cleanup +setup + +# Test 3: Force disable LFS with --no-lfs +echo " 2. Testing --no-lfs flag..." +init_samoyed --no-lfs + +# Verify NO LFS markers in hook scripts +pre_push_hook=".samoyed/_/pre-push" +if grep -q "SAMOYED_LFS_BEGIN" "$pre_push_hook"; then + error "LFS integration found when --no-lfs was specified" +fi + +if grep -q "git lfs" "$pre_push_hook"; then + error "git lfs commands found when --no-lfs was specified" +fi + +ok "LFS integration disabled with --no-lfs" + +cleanup +setup + +# Test 4: Auto-detection (no flags) +echo " 3. Testing auto-detection..." +init_samoyed + +# Just verify it doesn't crash - actual LFS detection depends on environment +if [ ! -f ".samoyed/_/pre-push" ]; then + error "Hooks not created with auto-detection" +fi + +ok "Auto-detection works without flags" + +cleanup + +echo "✓ All LFS flag tests passed" diff --git a/tests/integration/12_lfs_subcommand.sh b/tests/integration/12_lfs_subcommand.sh new file mode 100755 index 0000000..bae9cd3 --- /dev/null +++ b/tests/integration/12_lfs_subcommand.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env sh +# Test: samoyed lfs subcommand (enable, disable, status) + +integration_script_dir="$(cd "$(dirname "$0")" && pwd)" +integration_repo_root="$(cd "$integration_script_dir/../.." && pwd)" +cd "$integration_repo_root" +. "$integration_repo_root/tests/integration/functions.sh" + +parse_common_args "$@" +build_samoyed +setup + +echo "Test: samoyed lfs subcommand" + +# Test 1: Status before initialization +echo " 1. Testing 'samoyed lfs status' before init..." +output=$("$SAMOYED_BIN" lfs status 2>&1) + +# Check if git-lfs is available - if not, skip some tests +if echo "$output" | grep -q "Install git-lfs"; then + echo " ⚠ git-lfs not installed - skipping most lfs subcommand tests" + cleanup + echo "✓ All lfs subcommand tests passed (skipped - git-lfs not available)" + exit 0 +fi + +if ! echo "$output" | grep -q "Not initialized"; then + error "Expected 'Not initialized' message before init" +fi +ok "Status reports not initialized correctly" + +# Test 2: Initialize without LFS +echo " 2. Initializing without LFS..." +init_samoyed --no-lfs + +# Test 3: Status shows LFS disabled +echo " 3. Testing status shows LFS disabled..." +output=$("$SAMOYED_BIN" lfs status 2>&1) +if ! echo "$output" | grep -q "Disabled"; then + error "Expected LFS to be disabled: $output" +fi +ok "Status correctly shows LFS disabled" + +# Test 4: Enable LFS +echo " 4. Testing 'samoyed lfs enable'..." + +# Skip if git-lfs not installed +if ! command -v git-lfs >/dev/null 2>&1; then + echo " ⚠ Skipping lfs enable test (git-lfs not installed)" +else + "$SAMOYED_BIN" lfs enable + + # Verify hooks now have LFS integration + pre_push_hook=".samoyed/_/pre-push" + if ! grep -q "SAMOYED_LFS_BEGIN" "$pre_push_hook"; then + error "LFS not enabled in hooks after 'lfs enable'" + fi + + ok "LFS enabled successfully" + + # Test 5: Status shows LFS enabled + echo " 5. Testing status shows LFS enabled..." + output=$("$SAMOYED_BIN" lfs status 2>&1) + if ! echo "$output" | grep -q "Enabled"; then + error "Expected LFS to be enabled: $output" + fi + ok "Status correctly shows LFS enabled" + + # Test 6: Disable LFS + echo " 6. Testing 'samoyed lfs disable'..." + "$SAMOYED_BIN" lfs disable + + # Verify hooks no longer have LFS integration + if grep -q "SAMOYED_LFS_BEGIN" "$pre_push_hook"; then + error "LFS still present in hooks after 'lfs disable'" + fi + + ok "LFS disabled successfully" + + # Test 7: Status shows LFS disabled again + echo " 7. Testing status shows LFS disabled after disable..." + output=$("$SAMOYED_BIN" lfs status 2>&1) + if ! echo "$output" | grep -q "Disabled"; then + error "Expected LFS to be disabled after disable: $output" + fi + ok "Status correctly shows LFS disabled after toggling" +fi + +cleanup +setup + +# Test 8: Error handling - enable before init +echo " 8. Testing error when enabling before init..." +output=$("$SAMOYED_BIN" lfs enable 2>&1 || true) +if ! echo "$output" | grep -q "not initialized"; then + error "Expected error about not initialized: $output" +fi +ok "Proper error when not initialized" + +cleanup + +echo "✓ All lfs subcommand tests passed" diff --git a/tests/integration/13_hooks_d.sh b/tests/integration/13_hooks_d.sh new file mode 100755 index 0000000..e397781 --- /dev/null +++ b/tests/integration/13_hooks_d.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env sh +# Test: Hook composition mode (--hooks-d flag and hooks.d directory) + +integration_script_dir="$(cd "$(dirname "$0")" && pwd)" +integration_repo_root="$(cd "$integration_script_dir/../.." && pwd)" +cd "$integration_repo_root" +. "$integration_repo_root/tests/integration/functions.sh" + +parse_common_args "$@" +build_samoyed +setup + +echo "Test: Hook composition mode" + +# Test 1: Initialize with --hooks-d flag +echo " 1. Testing --hooks-d flag..." +init_samoyed --hooks-d + +# Verify hooks.d directory exists +if [ ! -d ".samoyed/hooks.d" ]; then + error "hooks.d directory not created" +fi + +ok "hooks.d directory created with --hooks-d flag" + +# Test 2: Verify hook scripts contain SAMOYED_HOOKS_D marker +echo " 2. Verifying hook composition code in hooks..." +pre_commit_hook=".samoyed/_/pre-commit" +if ! grep -q "SAMOYED_HOOKS_D" "$pre_commit_hook"; then + error "SAMOYED_HOOKS_D marker not found in hooks" +fi + +if ! grep -q 'hooks_d_dir="$(dirname "$0")/../hooks.d"' "$pre_commit_hook"; then + error "hooks.d directory reference not found in hooks" +fi + +ok "Hook scripts contain composition code" + +# Test 3: Create multiple hooks in hooks.d and test execution order +echo " 3. Testing hook execution order..." + +# Create output file to track execution order +output_file="hook_execution_order.txt" + +# Create first hook (should run first - lexicographic order) +cat > ".samoyed/hooks.d/10-first.pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "10-first" >> hook_execution_order.txt +EOF +chmod +x ".samoyed/hooks.d/10-first.pre-commit" + +# Create second hook (should run second) +cat > ".samoyed/hooks.d/20-second.pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "20-second" >> hook_execution_order.txt +EOF +chmod +x ".samoyed/hooks.d/20-second.pre-commit" + +# Create third hook (should run third) +cat > ".samoyed/hooks.d/30-third.pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "30-third" >> hook_execution_order.txt +EOF +chmod +x ".samoyed/hooks.d/30-third.pre-commit" + +# Create user hook in .samoyed/ (should run after hooks.d) +cat > ".samoyed/pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "user-hook" >> hook_execution_order.txt +EOF +chmod +x ".samoyed/pre-commit" + +# Create a test commit to trigger pre-commit hook +# Already in test directory +echo "test" > testfile.txt +git add testfile.txt +git commit -m "test commit" >/dev/null 2>&1 || true + +# Verify execution order +if [ ! -f "$output_file" ]; then + error "Hook execution order file not created" +fi + +# Check the order +order=$(cat "$output_file") +expected="10-first +20-second +30-third +user-hook" + +if [ "$order" != "$expected" ]; then + error "Execution order incorrect. Expected: +$expected +Got: +$order" +fi + +ok "Hooks execute in correct order (lexicographic + user hook last)" + +# Test 4: Hook failure propagation +echo " 4. Testing hook failure propagation..." + +# Already in test directory +git reset --hard HEAD~1 >/dev/null 2>&1 || true +rm -f "$output_file" + +# Create a failing hook +cat > ".samoyed/hooks.d/15-fail.pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "15-fail" >> hook_execution_order.txt +exit 1 +EOF +chmod +x ".samoyed/hooks.d/15-fail.pre-commit" + +# Try to commit - should fail +echo "test2" > testfile2.txt +git add testfile2.txt +if git commit -m "should fail" >/dev/null 2>&1; then + error "Commit succeeded when hook should have failed" +fi + +# Verify hooks stopped after failure (30-third should NOT have run) +if [ ! -f "$output_file" ]; then + error "No hooks ran before failure" +fi + +if grep -q "30-third" "$output_file"; then + error "Hooks continued after failure" +fi + +if ! grep -q "15-fail" "$output_file"; then + error "Failing hook did not execute" +fi + +ok "Hook failure stops execution chain" + +# Test 5: Non-executable hooks are skipped +echo " 5. Testing non-executable hooks are skipped..." + +# Already in test directory +rm -f "$output_file" +git reset --hard HEAD >/dev/null 2>&1 || true + +# Remove the failing hook +rm -f ".samoyed/hooks.d/15-fail.pre-commit" + +# Create a non-executable hook +cat > ".samoyed/hooks.d/25-notexec.pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "25-notexec" >> hook_execution_order.txt +EOF +# Don't make it executable + +# Commit should succeed and skip non-executable hook +echo "test3" > testfile3.txt +git add testfile3.txt +git commit -m "test commit 2" >/dev/null 2>&1 || error "Commit failed" + +# Verify non-executable hook was skipped +if grep -q "25-notexec" "$output_file"; then + error "Non-executable hook was executed" +fi + +# But others should have run +if ! grep -q "10-first" "$output_file"; then + error "Executable hooks did not run" +fi + +ok "Non-executable hooks are skipped" + +cleanup + +echo "✓ All hooks.d composition tests passed" diff --git a/tests/integration/14_existing_hooks.sh b/tests/integration/14_existing_hooks.sh new file mode 100755 index 0000000..2a6c1bc --- /dev/null +++ b/tests/integration/14_existing_hooks.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env sh +# Test: Existing hook detection and import + +integration_script_dir="$(cd "$(dirname "$0")" && pwd)" +integration_repo_root="$(cd "$integration_script_dir/../.." && pwd)" +cd "$integration_repo_root" +. "$integration_repo_root/tests/integration/functions.sh" + +parse_common_args "$@" +build_samoyed +setup + +echo "Test: Existing hook detection and import" + +# Test 1: Create existing hooks in .git/hooks +echo " 1. Setting up existing hooks..." + +# Create some existing hooks +cat > ".git/hooks/pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "Existing pre-commit hook" +exit 0 +EOF +chmod +x ".git/hooks/pre-commit" + +cat > ".git/hooks/pre-push" <<'EOF' +#!/usr/bin/env sh +echo "Existing pre-push hook" +exit 0 +EOF +chmod +x ".git/hooks/pre-push" + +ok "Created existing hooks in .git/hooks" + +# Test 2: Initialize without --hooks-d should warn +echo " 2. Testing warning without --hooks-d..." +output=$("$SAMOYED_BIN" init 2>&1) + +if ! echo "$output" | grep -q "Warning.*Existing.*hooks.*detected"; then + error "Expected warning about existing hooks" +fi + +if ! echo "$output" | grep -q "pre-commit"; then + error "Expected pre-commit to be listed" +fi + +if ! echo "$output" | grep -q "pre-push"; then + error "Expected pre-push to be listed" +fi + +if ! echo "$output" | grep -q "Use --hooks-d to import"; then + error "Expected suggestion to use --hooks-d" +fi + +ok "Warning displayed about existing hooks" + +# Test 3: Existing hooks become inactive without import +echo " 3. Verifying existing hooks are inactive..." + +# Create a test commit - existing hooks should NOT run +# Already in test directory +echo "test" > testfile.txt +git add testfile.txt + +# Capture commit output +commit_output=$(git commit -m "test" 2>&1 || true) + +# Old hooks should not run +if echo "$commit_output" | grep -q "Existing pre-commit hook"; then + error "Old hook still running after samoyed init" +fi + +ok "Existing hooks become inactive after init" + +# Clean up for next test +cleanup +setup + +# Test 4: Initialize with --hooks-d should import hooks +echo " 4. Testing import with --hooks-d..." + +# Create existing hooks again +cat > ".git/hooks/pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "IMPORTED_HOOK_RAN" >> imported_hook_test.txt +exit 0 +EOF +chmod +x ".git/hooks/pre-commit" + +cat > ".git/hooks/commit-msg" <<'EOF' +#!/usr/bin/env sh +echo "commit-msg hook" +exit 0 +EOF +chmod +x ".git/hooks/commit-msg" + +# Initialize with --hooks-d +output=$("$SAMOYED_BIN" init --hooks-d 2>&1) + +if ! echo "$output" | grep -q "Importing existing hooks"; then + error "Expected message about importing hooks" +fi + +# Verify hooks were imported +if [ ! -f ".samoyed/hooks.d/50-imported.pre-commit" ]; then + error "pre-commit hook not imported" +fi + +if [ ! -f ".samoyed/hooks.d/50-imported.commit-msg" ]; then + error "commit-msg hook not imported" +fi + +ok "Existing hooks imported to hooks.d/" + +# Test 5: Verify imported hooks have correct permissions +echo " 5. Testing imported hook permissions..." + +if [ ! -x ".samoyed/hooks.d/50-imported.pre-commit" ]; then + error "Imported hook is not executable" +fi + +ok "Imported hooks are executable" + +# Test 6: Verify imported hooks actually run +echo " 6. Testing imported hooks execute..." + +# Already in test directory +echo "test" > testfile.txt +git add testfile.txt +git commit -m "test" >/dev/null 2>&1 || error "Commit failed" + +if [ ! -f "imported_hook_test.txt" ]; then + error "Imported hook did not run" +fi + +if ! grep -q "IMPORTED_HOOK_RAN" "imported_hook_test.txt"; then + error "Imported hook did not execute correctly" +fi + +ok "Imported hooks execute during git operations" + +# Test 7: Test with custom hooks path (not .git/hooks) +cleanup +setup + +echo " 7. Testing with custom hooks path..." + +# Set custom hooks path +git config core.hooksPath ".custom-hooks" +mkdir -p ".custom-hooks" + +cat > ".custom-hooks/pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "Custom path hook" +exit 0 +EOF +chmod +x ".custom-hooks/pre-commit" + +# Initialize with --hooks-d +output=$("$SAMOYED_BIN" init --hooks-d 2>&1) + +if ! echo "$output" | grep -q "Existing.*hooks.*detected"; then + error "Did not detect hooks in custom path" +fi + +if ! echo "$output" | grep -q ".custom-hooks"; then + error "Custom hooks path not shown in warning" +fi + +# Verify hook was imported +if [ ! -f ".samoyed/hooks.d/50-imported.pre-commit" ]; then + error "Hook from custom path not imported" +fi + +ok "Hooks from custom path detected and imported" + +# Test 8: Test with absolute hooks path +cleanup +setup + +echo " 8. Testing with absolute hooks path..." + +# Create hooks directory outside repo +abs_hooks_dir="$WORKDIR/absolute-hooks" +mkdir -p "$abs_hooks_dir" + +cat > "$abs_hooks_dir/pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "Absolute path hook" +exit 0 +EOF +chmod +x "$abs_hooks_dir/pre-commit" + +# Set absolute hooks path +# Already in test directory +git config core.hooksPath "$abs_hooks_dir" + +# Initialize with --hooks-d +output=$("$SAMOYED_BIN" init --hooks-d 2>&1) + +if ! echo "$output" | grep -q "Existing.*hooks.*detected"; then + error "Did not detect hooks in absolute path" +fi + +# Verify hook was imported +if [ ! -f ".samoyed/hooks.d/50-imported.pre-commit" ]; then + error "Hook from absolute path not imported" +fi + +ok "Hooks from absolute path detected and imported" + +cleanup + +echo "✓ All existing hook detection and import tests passed" diff --git a/tests/integration/15_combined_features.sh b/tests/integration/15_combined_features.sh new file mode 100755 index 0000000..24b188d --- /dev/null +++ b/tests/integration/15_combined_features.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env sh +# Test: Combined features (LFS + hooks.d together) + +integration_script_dir="$(cd "$(dirname "$0")" && pwd)" +integration_repo_root="$(cd "$integration_script_dir/../.." && pwd)" +cd "$integration_repo_root" +. "$integration_repo_root/tests/integration/functions.sh" + +parse_common_args "$@" +build_samoyed +setup + +echo "Test: Combined features (LFS + hooks.d)" + +# Test 1: Initialize with both --with-lfs and --hooks-d +echo " 1. Testing --with-lfs --hooks-d together..." +init_samoyed --with-lfs --hooks-d + +# Verify hooks.d directory exists +if [ ! -d ".samoyed/hooks.d" ]; then + error "hooks.d directory not created" +fi + +ok "Both flags accepted together" + +# Test 2: Verify hooks have BOTH LFS and hooks.d code +echo " 2. Verifying combined template..." + +pre_push_hook=".samoyed/_/pre-push" +content=$(cat "$pre_push_hook") + +if ! echo "$content" | grep -q "SAMOYED_LFS_BEGIN"; then + error "LFS integration not found in combined mode" +fi + +if ! echo "$content" | grep -q "SAMOYED_HOOKS_D"; then + error "hooks.d composition not found in combined mode" +fi + +# Verify correct order: LFS first, then hooks.d, then user hook +lfs_line=$(grep -n "SAMOYED_LFS_BEGIN" "$pre_push_hook" | cut -d: -f1) +hooks_d_line=$(grep -n "SAMOYED_HOOKS_D" "$pre_push_hook" | cut -d: -f1) +source_line=$(grep -n '. "$(dirname "$0")/samoyed"' "$pre_push_hook" | tail -1 | cut -d: -f1) + +if [ "$lfs_line" -ge "$hooks_d_line" ]; then + error "LFS should come before hooks.d in template" +fi + +if [ "$hooks_d_line" -ge "$source_line" ]; then + error "hooks.d should come before user hook sourcing" +fi + +ok "Combined template has correct structure and order" + +# Test 3: Test execution order (LFS -> hooks.d -> user hook) +echo " 3. Testing execution order in combined mode..." + +# Create output file +output_file="execution_order.txt" + +# Note: We can't easily test LFS commands without actual LFS setup, +# but we can test hooks.d and user hook order + +# Create a hook in hooks.d +cat > ".samoyed/hooks.d/50-test.pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "hooks.d-script" >> execution_order.txt +EOF +chmod +x ".samoyed/hooks.d/50-test.pre-commit" + +# Create user hook +cat > ".samoyed/pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "user-hook" >> execution_order.txt +EOF +chmod +x ".samoyed/pre-commit" + +# Trigger hook +# Already in test directory +echo "test" > testfile.txt +git add testfile.txt +git commit -m "test" >/dev/null 2>&1 || true + +# Verify order +if [ ! -f "$output_file" ]; then + error "Execution order file not created" +fi + +order=$(cat "$output_file") +expected="hooks.d-script +user-hook" + +if [ "$order" != "$expected" ]; then + error "Execution order incorrect in combined mode" +fi + +ok "Execution order correct in combined mode" + +cleanup +setup + +# Test 4: Enable/disable LFS preserves hooks.d mode +echo " 4. Testing LFS toggle preserves hooks.d mode..." + +# Initialize with hooks.d only +init_samoyed --hooks-d + +pre_push_hook=".samoyed/_/pre-push" + +# Verify hooks.d but no LFS +if ! grep -q "SAMOYED_HOOKS_D" "$pre_push_hook"; then + error "hooks.d not enabled initially" +fi + +if grep -q "SAMOYED_LFS_BEGIN" "$pre_push_hook"; then + error "LFS should not be enabled initially" +fi + +# Skip if git-lfs not available +if ! command -v git-lfs >/dev/null 2>&1; then + echo " ⚠ Skipping LFS toggle test (git-lfs not installed)" +else + # Enable LFS + "$SAMOYED_BIN" lfs enable + + # Verify both LFS and hooks.d are present + if ! grep -q "SAMOYED_LFS_BEGIN" "$pre_push_hook"; then + error "LFS not enabled after lfs enable" + fi + + if ! grep -q "SAMOYED_HOOKS_D" "$pre_push_hook"; then + error "hooks.d mode lost after lfs enable" + fi + + ok "LFS enable preserves hooks.d mode" + + # Disable LFS + "$SAMOYED_BIN" lfs disable + + # Verify hooks.d still present, LFS removed + if grep -q "SAMOYED_LFS_BEGIN" "$pre_push_hook"; then + error "LFS still present after lfs disable" + fi + + if ! grep -q "SAMOYED_HOOKS_D" "$pre_push_hook"; then + error "hooks.d mode lost after lfs disable" + fi + + ok "LFS disable preserves hooks.d mode" +fi + +cleanup +setup + +# Test 5: Import existing hooks with LFS enabled +echo " 5. Testing hook import with LFS..." + +# Create existing hook +cat > ".git/hooks/pre-commit" <<'EOF' +#!/usr/bin/env sh +echo "EXISTING_HOOK" >> existing_test.txt +exit 0 +EOF +chmod +x ".git/hooks/pre-commit" + +# Initialize with both flags +init_samoyed --with-lfs --hooks-d + +# Verify imported hook exists +if [ ! -f ".samoyed/hooks.d/50-imported.pre-commit" ]; then + error "Existing hook not imported with combined flags" +fi + +# Test execution +# Already in test directory +echo "test" > testfile.txt +git add testfile.txt +git commit -m "test" >/dev/null 2>&1 || error "Commit failed" + +if [ ! -f "existing_test.txt" ]; then + error "Imported hook did not execute in combined mode" +fi + +ok "Existing hooks imported and execute with combined flags" + +# Test 6: Status command shows both features +echo " 6. Testing status shows both features..." + +output=$("$SAMOYED_BIN" lfs status 2>&1) + +if ! echo "$output" | grep -q "Hook composition mode.*Enabled"; then + error "Status should show hooks.d enabled" +fi + +ok "Status correctly shows both features" + +cleanup + +echo "✓ All combined feature tests passed" diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..0aa8eb8 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,242 @@ +# Samoyed Integration Tests + +This directory contains integration tests for Samoyed. Tests verify the complete workflow including initialization, hook execution, and various edge cases. + +## Running Tests + +### Local Execution (Serial) + +Run all tests locally in serial mode: + +```bash +# From repository root +cd tests/integration +for test in 0*.sh; do ./"$test"; done + +# Or using Make +make test-integration +``` + +### Docker Execution (Parallel) + +**Recommended approach** for fast, isolated testing: + +```bash +# Build image and run all tests in parallel +make test-docker-parallel + +# Or directly: +bash tests/integration/run-parallel-docker.sh +``` + +**Benefits:** +- Tests run in parallel (~10x faster) +- Complete isolation (no host contamination) +- Reproducible environment +- No local Rust toolchain needed + +### Docker Execution (Serial) + +Run tests one at a time in Docker: + +```bash +make test-docker +``` + +### Docker Compose + +Alternative orchestration using Docker Compose: + +```bash +make test-docker-compose + +# Or directly: +docker-compose -f docker-compose.test.yml up --abort-on-container-exit +docker-compose -f docker-compose.test.yml down +``` + +### Running Individual Tests + +#### Locally + +```bash +cd tests/integration +./01_default.sh +./02_custom_dir.sh --keep # Keep temp directory for debugging +``` + +#### In Docker + +```bash +docker build -t samoyed-test . +docker run --rm -e TEST_NAME=01_default.sh samoyed-test +``` + +## Test Suite + +| Test | Description | +|------|-------------| +| `01_default.sh` | Basic initialization and hook execution | +| `02_custom_dir.sh` | Custom directory names and nested paths | +| `03_from_subdir.sh` | Initialization from subdirectories | +| `04_not_git_dir.sh` | Error handling for non-git directories | +| `05_git_not_found.sh` | Handling missing git command | +| `06_command_not_found.sh` | Command not found error handling | +| `07_strict_mode.sh` | Shell strict mode compatibility | +| `08_samoyed_0.sh` | SAMOYED=0 bypass functionality | +| `09_init.sh` | Init command comprehensive tests | +| `10_time.sh` | Performance and timing tests | +| `11_lfs_flags.sh` | Git LFS integration with --with-lfs and --no-lfs flags | +| `12_lfs_subcommand.sh` | samoyed lfs enable/disable/status subcommands | +| `13_hooks_d.sh` | Hook composition mode (--hooks-d) and execution order | +| `14_existing_hooks.sh` | Existing hook detection, warnings, and import | +| `15_combined_features.sh` | Combined LFS + hooks.d features | + +## Architecture + +### Test Structure + +Each test: +1. Creates isolated temporary git repository +2. Initializes Samoyed +3. Tests specific functionality +4. Cleans up (unless `--keep` flag used) + +### Helper Functions + +`functions.sh` provides: +- `setup()` - Create test environment +- `cleanup()` - Remove test artifacts +- `init_samoyed()` - Initialize Samoyed in test repo +- `ok()`, `error()` - Test assertions +- Container detection for Docker execution + +### Containerization + +**Dockerfile** (multi-stage build): +``` +Stage 1 (builder): Compile Samoyed binary +Stage 2 (test-runner): Minimal Debian + Git + compiled binary +``` + +**Key features:** +- Binary built once, reused across all tests +- Tests run in isolated containers +- No shared state between tests +- Clean git configuration per container + +## Performance + +Typical execution times: + +| Method | Duration | Notes | +|--------|----------|-------| +| Local serial | ~30-40s | All tests run sequentially | +| Docker serial | ~35-45s | Sequential with container overhead | +| Docker parallel | ~8-12s | All tests run simultaneously | +| Docker Compose | ~10-15s | Similar to parallel script | + +## Troubleshooting + +### Test Failures + +View logs for failed tests: + +```bash +# Local execution shows output directly + +# Docker parallel execution shows last 50 lines of failed tests +bash tests/integration/run-parallel-docker.sh +``` + +### Debugging + +Keep temporary directory for inspection: + +```bash +# Local +./01_default.sh --keep + +# Docker (requires running interactively) +docker run --rm -it -e TEST_NAME=01_default.sh samoyed-test bash +# Then manually run: /tests/integration/01_default.sh +``` + +### Container Issues + +Build container without cache: + +```bash +docker build --no-cache -t samoyed-test -f Dockerfile . +``` + +Check if running in container: + +```bash +# Inside container, this returns 0: +is_containerized && echo "In container" || echo "Not in container" +``` + +## CI Integration + +Tests run in parallel in GitHub Actions: + +```yaml +- name: Run integration tests (parallel) + run: make test-docker-parallel +``` + +See `.github/workflows/ci.yml` for full configuration. + +## Adding New Tests + +1. Create `tests/integration/XX_name.sh` +2. Follow existing test structure +3. Add to `TEST_SCRIPTS` array in `run-parallel-docker.sh` +4. Add service to `docker-compose.test.yml` +5. Test locally before committing + +Example template: + +```bash +#!/usr/bin/env sh +# Test: Brief description + +integration_script_dir="$(cd "$(dirname "$0")" && pwd)" +integration_repo_root="$(cd "$integration_script_dir/../.." && pwd)" +cd "$integration_repo_root" +. "$integration_repo_root/tests/integration/functions.sh" + +parse_common_args "$@" +build_samoyed +setup + +# Your test code here +echo "Testing: Feature X" +init_samoyed +ok "Feature X works" + +cleanup +``` + +## Environment Variables + +| Variable | Purpose | Default | +|----------|---------|---------| +| `SAMOYED_BIN` | Path to binary | `target/release/samoyed` | +| `SAMOYED_TEST_CONTAINER` | Force container mode | unset | +| `SAMOYED_TEST_IMAGE` | Docker image name | `samoyed-test:latest` | +| `KEEP_WORKDIR` | Keep temp dir | `false` | + +## Requirements + +### Local Execution +- Rust toolchain (for building) +- Git +- Bash/sh +- Standard Unix utilities + +### Docker Execution +- Docker or Podman +- No Rust toolchain needed +- Works on Linux, macOS, Windows (WSL) diff --git a/tests/integration/functions.sh b/tests/integration/functions.sh index 0f32117..6c2ffc6 100755 --- a/tests/integration/functions.sh +++ b/tests/integration/functions.sh @@ -14,9 +14,59 @@ set -eu # runs inside a dedicated `mktemp -d` workspace outside the Samoyed repo # to avoid accidentally touching the project Git state. +# Detect if running in a container +is_containerized() { + # Check for Docker + [ -f /.dockerenv ] && return 0 + + # Check for Podman + [ -f /run/.containerenv ] && return 0 + + # Check environment variable + [ -n "${SAMOYED_TEST_CONTAINER:-}" ] && return 0 + + # Check cgroup for docker/containerd + if [ -f /proc/1/cgroup ]; then + grep -qE 'docker|lxc|containerd' /proc/1/cgroup 2>/dev/null && return 0 + fi + + return 1 +} + # Get the absolute path to the Samoyed binary -# We build it in release mode for testing real-world performance -SAMOYED_BIN="${SAMOYED_BIN:-$(pwd)/target/release/samoyed}" +# In containers, use the pre-installed binary in PATH +# Outside containers, build it in release mode for testing real-world performance +# Honor existing SAMOYED_BIN if already set (for manual test runs or special cases) +if [ -z "${SAMOYED_BIN:-}" ]; then + if is_containerized; then + SAMOYED_BIN="$(command -v samoyed || echo /usr/local/bin/samoyed)" + else + # Try target/release/samoyed first (normal builds without --target) + if [ -f "target/release/samoyed" ]; then + SAMOYED_BIN="$(pwd)/target/release/samoyed" + # Try target-specific directories (CI builds with --target flag) + elif [ -d "target" ]; then + # Look for target-specific builds in subdirectories + samoyed_bin_found=0 + for target_dir in target/*; do + # Skip if not a directory or is just 'release' or other special dirs + if [ -d "$target_dir" ] && [ -f "$target_dir/release/samoyed" ]; then + SAMOYED_BIN="$(pwd)/$target_dir/release/samoyed" + samoyed_bin_found=1 + break + fi + done + # Fallback to default path if still not found + if [ "$samoyed_bin_found" -eq 0 ]; then + SAMOYED_BIN="$(pwd)/target/release/samoyed" + fi + unset samoyed_bin_found + else + # Default path as ultimate fallback + SAMOYED_BIN="$(pwd)/target/release/samoyed" + fi + fi +fi # Remember the repository root so cleanup can return before deleting temp dirs ORIGINAL_WORKDIR="$(pwd)" @@ -336,12 +386,37 @@ expect_dir_exists() { # Build Samoyed binary if not already built # This ensures we're testing the current code build_samoyed() { + # In containers, binary is pre-installed (unless SAMOYED_BIN points to a valid file) + if is_containerized; then + # Check if SAMOYED_BIN is explicitly set and points to an existing file + if [ -n "$SAMOYED_BIN" ] && [ -f "$SAMOYED_BIN" ] && [ -x "$SAMOYED_BIN" ]; then + echo "Using binary from SAMOYED_BIN: $SAMOYED_BIN" + return 0 + fi + + # Otherwise expect samoyed in PATH + if ! command -v samoyed >/dev/null 2>&1; then + error "samoyed binary not found in container PATH" + fi + echo "Using pre-built samoyed binary: $(command -v samoyed)" + return 0 + fi + + # Outside containers, build if needed if [ ! -f "$SAMOYED_BIN" ]; then - echo "Building Samoyed binary..." + echo "Binary not found at: $SAMOYED_BIN" + echo "Attempting to build..." cargo build --release --quiet + # Re-check after build - might be in target/release now if [ ! -f "$SAMOYED_BIN" ]; then - error "Failed to build Samoyed binary at $SAMOYED_BIN" + # Try to find it in target/release after build + if [ -f "target/release/samoyed" ]; then + SAMOYED_BIN="$(pwd)/target/release/samoyed" + echo "Found binary at: $SAMOYED_BIN" + else + error "Failed to build Samoyed binary at $SAMOYED_BIN" + fi fi ok "Samoyed binary built successfully" diff --git a/tests/integration/run-parallel-docker.sh b/tests/integration/run-parallel-docker.sh new file mode 100755 index 0000000..1527b2e --- /dev/null +++ b/tests/integration/run-parallel-docker.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Parallel integration test runner for Samoyed using Docker +# This script builds the test image once and runs all tests in parallel + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +IMAGE_NAME="${SAMOYED_TEST_IMAGE:-samoyed-test:latest}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Find all test scripts +TEST_SCRIPTS=( + 01_default.sh + 02_custom_dir.sh + 03_from_subdir.sh + 04_not_git_dir.sh + 05_git_not_found.sh + 06_command_not_found.sh + 07_strict_mode.sh + 08_samoyed_0.sh + 09_init.sh + 10_time.sh + 11_lfs_flags.sh + 12_lfs_subcommand.sh + 13_hooks_d.sh + 14_existing_hooks.sh + 15_combined_features.sh +) + +echo "" +echo "========================================" +echo "Samoyed Integration Tests (Parallel)" +echo "========================================" +echo "" + +# Build container once +echo -e "${BLUE}Building test container image...${NC}" +cd "$REPO_ROOT" + +if docker build -t "$IMAGE_NAME" -f Dockerfile . 2>&1 | \ + grep -E "(^Step |^Successfully |ERROR|error:)"; then + echo -e "${GREEN}✓ Container image built successfully${NC}" +else + echo -e "${RED}✗ Failed to build container image${NC}" + exit 1 +fi + +echo "" +echo "========================================" +echo "Running ${#TEST_SCRIPTS[@]} tests in parallel..." +echo "========================================" +echo "" + +# Track results +declare -A test_results +declare -A test_pids +declare -A test_start_times +start_time=$(date +%s) + +# Create temporary directory for test outputs +TEST_OUTPUT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/samoyed-test-output.XXXXXX") +trap "rm -rf '$TEST_OUTPUT_DIR'" EXIT + +# Launch all tests in parallel +for test_script in "${TEST_SCRIPTS[@]}"; do + test_name="${test_script%.sh}" + output_file="$TEST_OUTPUT_DIR/$test_name.log" + result_file="$TEST_OUTPUT_DIR/$test_name.result" + + # Record start time + test_start_times["$test_name"]=$(date +%s) + + # Run test in isolated container + ( + # Run container and capture exit code + docker run --rm \ + --name "samoyed-test-$test_name-$$" \ + -e "TEST_NAME=$test_script" \ + "$IMAGE_NAME" \ + > "$output_file" 2>&1 + + exit_code=$? + + # Write result + echo "$exit_code" > "$result_file" + ) & + + test_pids["$test_name"]=$! + echo -e "${BLUE}→${NC} Started: $test_name (PID: ${test_pids[$test_name]})" +done + +echo "" +echo -e "${YELLOW}Waiting for tests to complete...${NC}" +echo "" + +# Wait for all tests and collect results +passed=0 +failed=0 +failed_tests=() + +for test_name in "${!test_pids[@]}"; do + pid=${test_pids[$test_name]} + output_file="$TEST_OUTPUT_DIR/$test_name.log" + result_file="$TEST_OUTPUT_DIR/$test_name.result" + + # Wait for specific test + wait "$pid" 2>/dev/null || true + + # Calculate duration + end_time=$(date +%s) + duration=$((end_time - test_start_times[$test_name])) + + # Read result + if [ -f "$result_file" ]; then + exit_code=$(cat "$result_file") + else + exit_code=1 + fi + + # Store result + test_results["$test_name"]=$exit_code + + # Display result with duration + if [ "$exit_code" -eq 0 ]; then + echo -e "${GREEN}✓ PASS${NC}: $test_name (${duration}s)" + ((passed++)) + else + echo -e "${RED}✗ FAIL${NC}: $test_name (${duration}s, exit code: $exit_code)" + ((failed++)) + failed_tests+=("$test_name") + fi +done + +# Show logs for failed tests +if [ $failed -gt 0 ]; then + echo "" + echo "========================================" + echo -e "${RED}Failed Test Logs${NC}" + echo "========================================" + for test_name in "${failed_tests[@]}"; do + output_file="$TEST_OUTPUT_DIR/$test_name.log" + echo "" + echo -e "${YELLOW}--- $test_name ---${NC}" + if [ -f "$output_file" ]; then + # Show last 50 lines of failed test + tail -n 50 "$output_file" | sed 's/^/ /' + else + echo " (no output captured)" + fi + done +fi + +end_time=$(date +%s) +total_duration=$((end_time - start_time)) + +# Summary +echo "" +echo "========================================" +echo "TEST RESULTS" +echo "========================================" +echo "Total: ${#TEST_SCRIPTS[@]} tests" +echo -e "Passed: ${GREEN}$passed${NC}" +echo -e "Failed: ${RED}$failed${NC}" +echo "Duration: ${total_duration}s" + +if [ $failed -gt 0 ]; then + echo "" + echo -e "${RED}Failed tests:${NC}" + for test in "${failed_tests[@]}"; do + echo " - $test" + done + echo "" + echo -e "${YELLOW}Tip: Re-run a specific test with:${NC}" + echo " docker run --rm -e TEST_NAME=.sh $IMAGE_NAME" + echo "" + exit 1 +else + echo "" + echo -e "${GREEN}✓ All tests passed!${NC}" + echo "" + exit 0 +fi diff --git a/tests/integration/validate-container-setup.sh b/tests/integration/validate-container-setup.sh new file mode 100644 index 0000000..5e34232 --- /dev/null +++ b/tests/integration/validate-container-setup.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Validation script for containerized test setup +# This script verifies that all components are in place + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +echo "========================================" +echo "Validating Containerized Test Setup" +echo "========================================" +echo "" + +errors=0 + +# Check Dockerfile +echo "Checking Dockerfile..." +if [ -f "$REPO_ROOT/Dockerfile" ]; then + echo " ✓ Dockerfile exists" + + # Validate it has multi-stage build + if grep -q "FROM.*AS builder" "$REPO_ROOT/Dockerfile" && \ + grep -q "FROM.*AS test-runner" "$REPO_ROOT/Dockerfile"; then + echo " ✓ Multi-stage build configured" + else + echo " ✗ Multi-stage build not properly configured" + ((errors++)) + fi + + # Check if samoyed binary is copied + if grep -q "COPY --from=builder.*samoyed" "$REPO_ROOT/Dockerfile"; then + echo " ✓ Binary copy configured" + else + echo " ✗ Binary copy not configured" + ((errors++)) + fi +else + echo " ✗ Dockerfile not found" + ((errors++)) +fi + +echo "" + +# Check .dockerignore +echo "Checking .dockerignore..." +if [ -f "$REPO_ROOT/.dockerignore" ]; then + echo " ✓ .dockerignore exists" +else + echo " ⚠ .dockerignore not found (optional but recommended)" +fi + +echo "" + +# Check functions.sh modifications +echo "Checking functions.sh..." +if [ -f "$SCRIPT_DIR/functions.sh" ]; then + echo " ✓ functions.sh exists" + + # Check for is_containerized function + if grep -q "^is_containerized()" "$SCRIPT_DIR/functions.sh"; then + echo " ✓ is_containerized() function added" + else + echo " ✗ is_containerized() function not found" + ((errors++)) + fi + + # Check build_samoyed modification + if grep -q "if is_containerized" "$SCRIPT_DIR/functions.sh"; then + echo " ✓ build_samoyed() modified for containers" + else + echo " ✗ build_samoyed() not modified for containers" + ((errors++)) + fi +else + echo " ✗ functions.sh not found" + ((errors++)) +fi + +echo "" + +# Check parallel runner script +echo "Checking parallel runner..." +if [ -f "$SCRIPT_DIR/run-parallel-docker.sh" ]; then + echo " ✓ run-parallel-docker.sh exists" + + if [ -x "$SCRIPT_DIR/run-parallel-docker.sh" ]; then + echo " ✓ Script is executable" + else + echo " ⚠ Script is not executable (run: chmod +x)" + fi + + # Check for proper error handling + if grep -q "set -euo pipefail" "$SCRIPT_DIR/run-parallel-docker.sh"; then + echo " ✓ Error handling configured" + else + echo " ✗ Error handling not configured" + ((errors++)) + fi +else + echo " ✗ run-parallel-docker.sh not found" + ((errors++)) +fi + +echo "" + +# Check Docker Compose file +echo "Checking Docker Compose..." +if [ -f "$REPO_ROOT/docker-compose.test.yml" ]; then + echo " ✓ docker-compose.test.yml exists" + + # Count test services + service_count=$(grep -c "test-[0-9]*-" "$REPO_ROOT/docker-compose.test.yml" || true) + echo " ✓ Found $service_count test services defined" +else + echo " ✗ docker-compose.test.yml not found" + ((errors++)) +fi + +echo "" + +# Check Makefile +echo "Checking Makefile..." +if [ -f "$REPO_ROOT/Makefile" ]; then + echo " ✓ Makefile exists" + + if grep -q "test-docker-parallel" "$REPO_ROOT/Makefile"; then + echo " ✓ test-docker-parallel target exists" + else + echo " ⚠ test-docker-parallel target not found" + fi +else + echo " ⚠ Makefile not found (optional)" +fi + +echo "" + +# Test container detection +echo "Testing container detection..." +cd "$REPO_ROOT" +detection_result=$(bash -c ' + source tests/integration/functions.sh + if is_containerized; then + echo "container" + else + echo "host" + fi +') +echo " Detected environment: $detection_result" + +echo "" + +# Summary +echo "========================================" +echo "Validation Summary" +echo "========================================" +if [ $errors -eq 0 ]; then + echo "✓ All checks passed!" + echo "" + echo "Next steps:" + echo " 1. Build the image: docker build -t samoyed-test ." + echo " 2. Run parallel tests: make test-docker-parallel" + echo " 3. Or use: bash tests/integration/run-parallel-docker.sh" + exit 0 +else + echo "✗ Found $errors error(s)" + echo "" + echo "Please fix the errors above before using containerized tests." + exit 1 +fi