diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae3a5a8..99e0d2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,29 +13,8 @@ permissions: packages: write jobs: - release: - name: Create Release - runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: Release ${{ github.ref }} - draft: false - prerelease: false - - build-and-upload: - name: Build and Upload Assets - needs: release + build: + name: Build Release Assets runs-on: ubuntu-latest strategy: matrix: @@ -50,17 +29,23 @@ jobs: arch: arm64 - os: windows arch: amd64 - + steps: - name: Checkout code uses: actions/checkout@v4 - + with: + fetch-depth: 0 # Get full history for proper version info + - name: Set up Go uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - - - name: Build binary + + - name: Get build time + id: build_time + run: echo "build_time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + + - name: Build main binary env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} @@ -69,22 +54,89 @@ jobs: if [ "${{ matrix.os }}" = "windows" ]; then binary_name="${binary_name}.exe" fi - CGO_ENABLED=1 go build -ldflags="-s -w -X main.Version=${{ github.ref_name }}" -o "$binary_name" main.go + + # Build with version, commit, and build time ldflags + CGO_ENABLED=1 go build -ldflags="-s -w \ + -X codechunking/cmd.Version=${{ github.ref_name }} \ + -X codechunking/cmd.Commit=${{ github.sha }} \ + -X codechunking/cmd.BuildTime=${{ steps.build_time.outputs.build_time }}" \ + -o "$binary_name" main.go + + # Create tarball tar czf "${binary_name}.tar.gz" "$binary_name" - - - name: Upload Release Asset - uses: actions/upload-release-asset@v1 + + - name: Build client binary env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + run: | + client_binary="codechunking-client-${{ matrix.os }}-${{ matrix.arch }}" + if [ "${{ matrix.os }}" = "windows" ]; then + client_binary="${client_binary}.exe" + fi + + # Build client binary (static, no CGO) + CGO_ENABLED=0 go build -ldflags="-s -w" \ + -o "$client_binary" ./cmd/client + + # Create tarball + tar czf "${client_binary}.tar.gz" "$client_binary" + + - name: Generate checksums + run: | + # Create checksums file + echo "# Checksums for ${{ github.ref_name }}" > checksums.txt + + # Add checksums for all artifacts + for file in *.tar.gz; do + if [ -f "$file" ]; then + sha256sum "$file" >> checksums.txt + fi + done + + # Display checksums for verification + cat checksums.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: release-assets-${{ matrix.os }}-${{ matrix.arch }} + path: | + *.tar.gz + checksums.txt + retention-days: 1 + + create-release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 with: - upload_url: ${{ needs.release.outputs.upload_url }} - asset_path: ./codechunking-${{ matrix.os }}-${{ matrix.arch }}.tar.gz - asset_name: codechunking-${{ matrix.os }}-${{ matrix.arch }}.tar.gz - asset_content_type: application/gzip + path: artifacts + merge-multiple: true + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref }} + name: Release ${{ github.ref }} + draft: false + prerelease: false + files: | + artifacts/*.tar.gz + artifacts/checksums.txt + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} docker-release: name: Docker Release - needs: release + needs: create-release runs-on: ubuntu-latest steps: - name: Checkout code @@ -118,6 +170,10 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} + - name: Get build time + id: docker_build_time + run: echo "build_time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT + - name: Build and push Docker image uses: docker/build-push-action@v5 with: @@ -126,6 +182,14 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: | + ${{ steps.meta.outputs.labels }} + org.opencontainers.image.version=${{ github.ref_name }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.created=${{ steps.docker_build_time.outputs.build_time }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} + BUILD_TIME=${{ steps.docker_build_time.outputs.build_time }} cache-from: type=gha cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.gitignore b/.gitignore index 173b7ea..fdbaca8 100644 --- a/.gitignore +++ b/.gitignore @@ -76,7 +76,9 @@ tokens.txt # Temporary test scripts scripts/*.go -scripts/*.sh # Local analysis files -countTokens.py \ No newline at end of file +countTokens.py + +# VERSION file is not used - version comes from git tags +VERSION \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..f983c0c --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,538 @@ +# Installation Guide + +This guide provides detailed installation instructions for CodeChunking, a production-grade semantic code search system. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Installation Methods](#installation-methods) + - [Method 1: Go Install](#method-1-go-install) + - [Method 2: Pre-built Binaries](#method-2-pre-built-binaries) + - [Method 3: Build from Source](#method-3-build-from-source) + - [Method 4: Docker](#method-4-docker) +- [Version Management](#version-management) +- [Binary Differences](#binary-differences) +- [Cross-Platform Builds](#cross-platform-builds) +- [Troubleshooting](#troubleshooting) +- [Environment Setup](#environment-setup) + +## Prerequisites + +### System Requirements + +- **Go 1.24+** (required for building from source) +- **Git** (for repository cloning and version info) +- **CGO toolchain** (only for main binary) + - Linux: `gcc` or `clang` + - macOS: Xcode Command Line Tools + - Windows: MinGW-w64 or TDM-GCC + +### Optional Requirements + +- **Docker** (for containerized deployment) +- **PostgreSQL** with pgvector extension +- **NATS** JetStream server +- **Google Gemini API key** (for embeddings) + +## Installation Methods + +### Method 1: Go Install + +This method installs Go binaries directly from the repository. + +#### Main Binary Installation + +```bash +# Set CGO_ENABLED=1 for tree-sitter support +export CGO_ENABLED=1 +go install github.com/Anthony-Bible/codechunking/cmd/codechunking@latest +``` + +#### Client Binary Installation + +```bash +# Client binary doesn't require CGO +go install github.com/Anthony-Bible/codechunking/cmd/client@latest +``` + +#### Verify Installation + +```bash +# Check main binary +codechunking version + +# Check client binary +codechunking-client version +``` + +#### Go Install Troubleshooting + +**Issue: CGO errors during installation** +```bash +# Ensure CGO is enabled +export CGO_ENABLED=1 + +# On Ubuntu/Debian, install build tools +sudo apt-get update +sudo apt-get install build-essential + +# On macOS, install Xcode tools +xcode-select --install + +# On Windows, install TDM-GCC and add to PATH +``` + +**Issue: Module path not found** +```bash +# Ensure you're using Go 1.24+ +go version + +# Try with explicit version +CGO_ENABLED=1 go install github.com/Anthony-Bible/codechunking/cmd/codechunking@v1.0.0 +``` + +### Method 2: Pre-built Binaries + +Download pre-compiled binaries from GitHub releases for your platform. + +#### Finding Releases + +Visit: https://github.com/Anthony-Bible/codechunking/releases + +#### Downloading for Linux + +```bash +# Determine your architecture +ARCH=$(uname -m) +case $ARCH in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +# Download latest version +VERSION=$(curl -s https://api.github.com/repos/Anthony-Bible/codechunking/releases/latest | grep -o '"tag_name": "[^"]*' | cut -d'"' -f2) + +# Download main binary +wget "https://github.com/Anthony-Bible/codechunking/releases/download/${VERSION}/codechunking-${ARCH}" +chmod +x "codechunking-${ARCH}" +sudo mv "codechunking-${ARCH}" /usr/local/bin/codechunking + +# Download client binary +wget "https://github.com/Anthony-Bible/codechunking/releases/download/${VERSION}/client-${ARCH}" +chmod +x "client-${ARCH}" +sudo mv "client-${ARCH}" /usr/local/bin/codechunking-client +``` + +#### Downloading for macOS + +```bash +# Using Homebrew (if available) +brew install codechunking + +# Or manually download +ARCH=$(uname -m) +case $ARCH in + x86_64) ARCH="amd64" ;; + arm64) ARCH="arm64" ;; +esac + +VERSION=$(curl -s https://api.github.com/repos/Anthony-Bible/codechunking/releases/latest | grep -o '"tag_name": "[^"]*' | cut -d'"' -f2) + +curl -L "https://github.com/Anthony-Bible/codechunking/releases/download/${VERSION}/codechunking-darwin-${ARCH}" -o codechunking +chmod +x codechunking +sudo mv codechunking /usr/local/bin/ +``` + +#### Downloading for Windows + +```powershell +# Using PowerShell +$Version = (Invoke-RestMethod -Uri "https://api.github.com/repos/Anthony-Bible/codechunking/releases/latest").tag_name + +# Download main binary +Invoke-WebRequest -Uri "https://github.com/Anthony-Bible/codechunking/releases/download/$Version/codechunking-windows-amd64.exe" -OutFile "codechunking.exe" +# Download client binary +Invoke-WebRequest -Uri "https://github.com/Anthony-Bible/codechunking/releases/download/$Version/client-windows-amd64.exe" -OutFile "codechunking-client.exe" + +# Add to PATH or move to desired location +``` + +#### Verifying Checksums + +Always verify downloaded binaries: + +```bash +# Download checksums +wget "https://github.com/Anthony-Bible/codechunking/releases/download/${VERSION}/checksums.txt" + +# Verify main binary +sha256sum codechunking-${ARCH} | grep -f checksums.txt + +# Verify client binary +sha256sum client-${ARCH} | grep -f checksums.txt +``` + +### Method 3: Build from Source + +Build binaries directly from source code. + +#### Quick Build + +```bash +git clone https://github.com/Anthony-Bible/codechunking.git +cd codechunking +make build +``` + +The binaries will be created in `./bin/`: +- `bin/codechunking` - Main application +- `bin/client` - Client binary + +#### Detailed Build Instructions + +```bash +# Clone repository +git clone https://github.com/Anthony-Bible/codechunking.git +cd codechunking + +# Install dependencies +go mod download + +# Build with version +./scripts/build.sh v1.0.0 + +# Or cross-compile +./scripts/build.sh --platform linux/amd64 v1.0.0 +``` + +#### Build Script Options + +The build script (`scripts/build.sh`) supports multiple options: + +```bash +# Show help +./scripts/build.sh --help + +# Clean build +./scripts/build.sh --clean v1.0.0 + +# Verbose build +./scripts/build.sh --verbose v1.0.0 + +# Cross-platform builds +./scripts/build.sh --platform linux/amd64 v1.0.0 +./scripts/build.sh --platform darwin/arm64 v1.0.0 +./scripts/build.sh --platform windows/amd64 v1.0.0 + +# Build with test optimizations +TEST_MODE=true ./scripts/build.sh v1.0.0 +``` + +#### Make Commands + +```bash +# Build both binaries (uses build script) +make build + +# Build with specific version +make build-with-version VERSION=v1.0.0 + +# Build only main binary (legacy method) +make build-main + +# Build only client binary (no CGO) +make build-client + +# Install both binaries to GOPATH/bin +make install + +# Install only client binary +make install-client + +# Install development tools +make install-tools + +# Clean build artifacts +make clean +``` + +### Method 4: Docker + +#### Using Pre-built Docker Image + +```bash +# Pull image +docker pull ghcr.io/anthony-bible/codechunking:latest + +# Run container +docker run -p 8080:8080 ghcr.io/anthony-bible/codechunking:latest +``` + +#### Building Docker Image + +```bash +# Clone repository +git clone https://github.com/Anthony-Bible/codechunking.git +cd codechunking + +# Build image +docker build -t codechunking . + +# Or with version +docker build -t codechunking:v1.0.0 . + +# Run with environment variables +docker run -p 8080:8080 \ + -e CODECHUNK_DATABASE_HOST=host.docker.internal \ + -e CODECHUNK_NATS_URL=nats://host.docker.internal:4222 \ + codechunking +``` + +#### Docker Compose + +```bash +# Start all services +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop services +docker-compose down +``` + +## Version Management + +### Checking Version + +```bash +# Full version information +codechunking version + +# Example output: +CodeChunking CLI +Version: v1.0.0 +Commit: abc123def456 +Built: + +# Short version +codechunking version --short +# Output: v1.0.0 +``` + +### Installing Specific Versions + +```bash +# Go install specific version +CGO_ENABLED=1 go install github.com/Anthony-Bible/codechunking/cmd/codechunking@v1.0.0 + +# Download specific version binary +wget https://github.com/Anthony-Bible/codechunking/releases/download/v1.0.0/codechunking-linux-amd64 +``` + +### Version Format + +Versions follow Semantic Versioning: `v..` +- `v1.0.0` - Stable release +- `v1.1.0-beta` - Pre-release +- `v1.0.1` - Patch release + +## Binary Differences + +| Feature | Main Binary (`codechunking`) | Client Binary (`codechunking-client`) | +|---------|----------------------------|---------------------------------------| +| API Server | ✓ | ✗ | +| Worker | ✓ | ✗ | +| File Processing | ✓ (tree-sitter) | ✗ | +| Repository Management | ✓ | ✗ | +| API Client | ✗ | ✓ | +| CGO Required | ✓ | ✗ | +| Binary Size | ~15-20MB | ~5-8MB | +| Dependencies | tree-sitter, CGO | None | + +### When to Use Which Binary + +**Use Main Binary (`codechunking`) when:** +- Running the API server +- Processing repositories +- Running background workers +- Need full functionality + +**Use Client Binary (`codechunking-client`) when:** +- Interacting with existing API +- CI/CD pipelines +- AI agent integration +- Minimal dependencies required + +## Cross-Platform Builds + +Building for multiple platforms: + +```bash +# Build for all platforms +./scripts/build.sh --platform linux/amd64 v1.0.0 +./scripts/build.sh --platform linux/arm64 v1.0.0 +./scripts/build.sh --platform darwin/amd64 v1.0.0 +./scripts/build.sh --platform darwin/arm64 v1.0.0 +./scripts/build.sh --platform windows/amd64 v1.0.0 + +# Environment variables for cross-compilation +export GOOS=linux +export GOARCH=arm64 +./scripts/build.sh v1.0.0 +``` + +### Supported Platforms + +- **Linux**: amd64, arm64 +- **macOS**: amd64, arm64 (Apple Silicon) +- **Windows**: amd64 + +## Troubleshooting + +### Common Issues + +1. **Permission Denied** + ```bash + chmod +x codechunking + chmod +x codechunking-client + ``` + +2. **Command Not Found** + ```bash + # Add to PATH + echo 'export PATH=$PATH:/usr/local/bin' >> ~/.bashrc + source ~/.bashrc + ``` + +3. **CGO Errors** + ```bash + # Install CGO dependencies + # Ubuntu/Debian: + sudo apt-get install build-essential + + # macOS: + xcode-select --install + + # Windows: + # Install MinGW-w64 or TDM-GCC + ``` + +4. **Tree-sitter Build Failures** + ```bash + # Ensure CGO is enabled + export CGO_ENABLED=1 + + # Clean build + ./scripts/build.sh --clean v1.0.0 + ``` + +5. **Version Information Missing** + ```bash + # Build with explicit version + ./scripts/build.sh v1.0.0 + + # Or let git describe determine version (recommended) + ./scripts/build.sh + # Uses: git describe --tags --always --dirty + ``` + +6. **Cross-compilation Issues** + ```bash + # For Windows, need MinGW + sudo apt-get install gcc-mingw-w64-x86-64 + + # For arm64, need cross-compiler + sudo apt-get install gcc-aarch64-linux-gnu + ``` + +### Getting Help + +```bash +# Common help commands +codechunking --help +codechunking-client --help + +# Specific command help +codechunking api --help +codechunking-client repos --help + +# Version info for debugging +codechunking version --verbose +``` + +### Debug Mode + +Enable debug logging: + +```bash +# Set log level +export CODECHUNK_LOG_LEVEL=debug + +# Or use flag +codechunking --log-level debug api +``` + +## Environment Setup + +### Development Environment + +```bash +# Clone repository +git clone https://github.com/Anthony-Bible/codechunking.git +cd codechunking + +# Install development tools +make install-tools + +# Set up environment +cp .env.example .env +# Edit .env with your configuration + +# Start development services +make dev +make migrate-up + +# Run tests +make test +``` + +### Production Environment + +```bash +# Required environment variables +export CODECHUNK_DATABASE_HOST=localhost +export CODECHUNK_DATABASE_PORT=5432 +export CODECHUNK_DATABASE_USER=codechunking +export CODECHUNK_DATABASE_PASSWORD=your_password +export CODECHUNK_DATABASE_NAME=codechunking + +# Optional but recommended +export CODECHUNK_GEMINI_API_KEY=your_api_key +export CODECHUNK_LOG_LEVEL=info +export CODECHUNK_API_ENABLE_DEFAULT_MIDDLEWARE=true +``` + +### Client-Side Environment + +For the client binary: + +```bash +# Optional configuration +export CODECHUNK_CLIENT_API_URL=http://localhost:8080 +export CODECHUNK_CLIENT_TIMEOUT=30s +export CODECHUNK_CLIENT_RETRY_ATTEMPTS=3 +``` + +## Next Steps + +After installation: + +1. **Set up Database**: Configure PostgreSQL with pgvector +2. **Start Services**: Run API server and worker +3. **Configure**: Set up environment variables +4. **Test API**: Verify with health check endpoint +5. **Index Repository**: Add your first repository + +For detailed configuration and usage, see the main [README.md](README.md) and project [wiki](wiki/). \ No newline at end of file diff --git a/Makefile b/Makefile index b4e7d53..c44f949 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev test build migrate clean help build-client build-client-cross test-client +.PHONY: dev test build migrate clean help build-client build-client-cross test-client install install-client build-with-version # Variables BINARY_NAME=codechunking @@ -6,6 +6,18 @@ DOCKER_COMPOSE=docker compose GO_CMD=go MIGRATE_CMD=migrate +# Version and installation variables +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS = -ldflags "-X codechunking/cmd.Version=$(VERSION) -X codechunking/cmd.Commit=$(shell git rev-parse HEAD 2>/dev/null || echo unknown) -X codechunking/cmd.BuildTime=$(BUILD_TIME)" + +# Installation directory detection +ifeq ($(OS),Windows_NT) + INSTALL_DIR ?= $(shell echo $$USERPROFILE/bin) +else + INSTALL_DIR ?= $(shell $(GO_CMD) env GOPATH)/bin +endif + # Default target all: build @@ -71,10 +83,11 @@ test-coverage: ## test-all: Run all tests with coverage test-all: test test-integration test-coverage -## build: Build the binary +## build: Build both main and client binaries using build script +## Version: uses git describe (or "dev" if no git) build: - CGO_ENABLED=1 $(GO_CMD) build -o bin/$(BINARY_NAME) main.go - @echo "Binary built: bin/$(BINARY_NAME)" + @./scripts/build.sh + @echo "Binaries built: bin/codechunking and bin/client" ## build-linux: Build for Linux build-linux: @@ -98,6 +111,37 @@ build-client-cross: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO_CMD) build -o bin/codechunking-client-darwin-arm64 ./cmd/client @echo "Cross-compiled client binaries built in bin/" +## build-with-version: Build the binary with version injection using build script +## Uses VERSION from command line or auto-detects from git tags (default: dev) +## Example: make build-with-version VERSION=v1.0.0 +build-with-version: + @./scripts/build.sh $(VERSION) + @echo "Binaries built with version $(VERSION): bin/codechunking and bin/client" + +## install: Build and install both binaries to $(INSTALL_DIR) +install: build + @mkdir -p $(INSTALL_DIR) + @if [ -f "bin/codechunking" ]; then \ + cp bin/codechunking $(INSTALL_DIR)/; \ + echo "Installed codechunking to $(INSTALL_DIR)/codechunking"; \ + else \ + echo "Warning: bin/codechunking not found"; \ + fi + @if [ -f "bin/client" ]; then \ + cp bin/client $(INSTALL_DIR)/codechunking-client; \ + echo "Installed codechunking-client to $(INSTALL_DIR)/codechunking-client"; \ + else \ + echo "Warning: bin/client not found"; \ + fi + @echo "Make sure $(INSTALL_DIR) is in your PATH" + +## install-client: Build and install client binary to $(INSTALL_DIR) +install-client: build-client + @mkdir -p $(INSTALL_DIR) + @cp bin/codechunking-client $(INSTALL_DIR)/ + @echo "Installed codechunking-client to $(INSTALL_DIR)/codechunking-client" + @echo "Make sure $(INSTALL_DIR) is in your PATH" + ## migrate-up: Apply all database migrations migrate-up: $(GO_CMD) run main.go migrate up --config configs/config.dev.yaml diff --git a/README.md b/README.md index 039dae1..f3783a8 100644 --- a/README.md +++ b/README.md @@ -315,19 +315,40 @@ For detailed API documentation, see the [wiki](wiki/). ## Installation -### Using Go +### Option 1: Go Install (Recommended for Development) ```bash -go install github.com/Anthony-Bible/codechunking@latest +# Install main binary (requires CGO_ENABLED=1 for tree-sitter) +CGO_ENABLED=1 go install github.com/Anthony-Bible/codechunking/cmd/codechunking@latest + +# Install client binary only (lightweight, no CGO required) +go install github.com/Anthony-Bible/codechunking/cmd/client@latest ``` -### Using Docker +**Note**: The main binary requires CGO_ENABLED=1 due to tree-sitter dependencies. The client binary is standalone and doesn't require CGO. + +### Option 2: Download Pre-built Binaries + +Download pre-compiled binaries from GitHub releases: ```bash -docker pull yourusername/codechunking:latest +# Example for Linux amd64 +wget https://github.com/Anthony-Bible/codechunking/releases/latest/download/codechunking-v1.0.0 +chmod +x codechunking-v1.0.0 +sudo mv codechunking-v1.0.0 /usr/local/bin/codechunking + +# Client binary +wget https://github.com/Anthony-Bible/codechunking/releases/latest/download/client-v1.0.0 +chmod +x client-v1.0.0 +sudo mv client-v1.0.0 /usr/local/bin/codechunking-client ``` -### From source +Available platforms: +- Linux (amd64, arm64) +- macOS (amd64, arm64) +- Windows (amd64) + +### Option 3: Build from Source ```bash git clone https://github.com/Anthony-Bible/codechunking.git @@ -335,6 +356,45 @@ cd codechunking make build ``` +The build script creates two binaries in `./bin/`: +- `codechunking` - Main application with tree-sitter support +- `client` - Lightweight client binary + +### Option 4: Using Docker + +```bash +docker pull ghcr.io/anthony-bible/codechunking:latest +``` + +### Version Verification + +Check installed version: + +```bash +# Main binary +codechunking version + +# Example output: +CodeChunking CLI +Version: v3.0.0 +Commit: abc123def456 +Built: 2024-06-01T12:00:00Z + +# Client binary +codechunking-client version + +# Short version output +codechunking version --short +v1.0.0 +``` + +### Binary Differences + +- **Main Binary (`codechunking`)**: Full-featured application including API server, worker, and file processing. Requires CGO for tree-sitter code parsing. +- **Client Binary (`codechunking-client`)**: Lightweight standalone CLI for API interaction. No dependencies, ideal for CI/CD and AI agents. + +For detailed installation instructions and troubleshooting, see [INSTALL.md](INSTALL.md). + ## Usage ### CLI Commands @@ -360,6 +420,15 @@ make migrate-create name=add_new_feature # Show version codechunking version +# Expected output: +# CodeChunking CLI +# Version: v1.0.0 +# Commit: abc123def456 +# Built: + +# Short version +codechunking version --short +# Expected output: v1.0.0 ``` #### Chunk Command diff --git a/cmd/root.go b/cmd/root.go index 6ccf334..a13a339 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,10 +34,12 @@ var rootCmd = newRootCmd() //nolint:gochecknoglobals // Standard Cobra CLI patte // newRootCmd creates and returns the root command. func newRootCmd() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "codechunking", Short: "A code chunking and retrieval system", - Long: `CodeChunking is a production-grade system for indexing code repositories, + Long: `CodeChunking - A code chunking and retrieval system + +CodeChunking is a production-grade system for indexing code repositories, generating embeddings, and providing semantic code search capabilities. The system supports: @@ -46,12 +48,32 @@ The system supports: - Embedding generation with Google Gemini - Vector storage and similarity search with PostgreSQL/pgvector - Asynchronous job processing with NATS JetStream`, + Run: func(c *cobra.Command, _ []string) { + // Default behavior: show help when no command provided + _ = c.Help() // Help prints to stdout and returns an error we can ignore + }, } + + // Add version flag to root command + cmd.PersistentFlags().BoolP("version", "v", false, "Show version information") + + return cmd } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { + // Check for version flag before running command to bypass config initialization + args := os.Args[1:] + if len(args) > 0 && (args[0] == "--version" || args[0] == "-v") { + err := runVersion(rootCmd, false) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + err := rootCmd.Execute() if err != nil { os.Exit(1) @@ -77,6 +99,12 @@ func init() { //nolint:gochecknoinits // Standard Cobra CLI pattern for root com } func initConfig() { + // Check if we're just showing version - if so, skip config initialization + // This handles both the --version/-v flag and the "version" subcommand + if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v" || os.Args[1] == "version") { + return + } + v := viper.New() // Set defaults @@ -126,8 +154,16 @@ func initConfig() { }) } - // Load configuration - cmdConfig.cfg = config.New(v) + // Load full configuration only if required database settings are present. + // This intentional design allows version/help commands and tests to work + // without requiring full database configuration, improving CLI usability. + // Commands that need database access will fail gracefully if config is missing. + if v.IsSet("database.user") { + cmdConfig.cfg = config.New(v) + } else { + // Create minimal config for commands that don't need database (version, help) + cmdConfig.cfg = &config.Config{} + } } // bindMiddlewareEnvVars explicitly binds middleware environment variables to Viper configuration keys. @@ -243,6 +279,7 @@ func SetTestConfig(c *config.Config) { // handleConfigError handles configuration file loading errors with detailed logging. func handleConfigError(err error, configFile string, searchPaths []string) { + // Check if it's a config file not found error var configFileNotFoundError viper.ConfigFileNotFoundError if errors.As(err, &configFileNotFoundError) { handleConfigNotFound(err, searchPaths) diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..ee0bef2 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "bytes" + "codechunking/internal/version" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRootCommand_VersionFlag verifies that the --version flag works on the root command. +func TestRootCommand_VersionFlag(t *testing.T) { + tests := []struct { + name string + args []string + version string + commit string + buildTime string + wantContains []string + wantErr bool + }{ + { + name: "--version flag with complete info", + args: []string{"--version"}, + version: "v2.0.0", + commit: "def456abc789", + buildTime: "2025-06-15T10:30:00Z", + wantContains: []string{ + "CodeChunking CLI", + "Version: v2.0.0", + "Commit: def456abc789", + "Built: 2025-06-15T10:30:00Z", + }, + wantErr: false, + }, + { + name: "-v short flag", + args: []string{"-v"}, + version: "v1.5.0", + commit: "short123", + buildTime: "2025-06-15T10:30:00Z", + wantContains: []string{ + "CodeChunking CLI", + "Version: v1.5.0", + "Commit: short123", + "Built: 2025-06-15T10:30:00Z", + }, + wantErr: false, + }, + { + name: "version flag with empty values", + args: []string{"--version"}, + version: "", + commit: "", + buildTime: "", + wantContains: []string{ + "CodeChunking CLI", + "Version: dev", + "Commit: unknown", + "Built: unknown", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set version variables for this test + originalVersion := Version + originalCommit := Commit + originalBuildTime := BuildTime + defer func() { + Version = originalVersion + Commit = originalCommit + BuildTime = originalBuildTime + version.ResetBuildVars() // Reset version package state after test + }() + + // Reset version package state before setting new values + version.ResetBuildVars() + // Set both legacy variables (for sync) and version package + Version = tt.version + Commit = tt.commit + BuildTime = tt.buildTime + version.SetBuildVars(tt.version, tt.commit, tt.buildTime) + + // Create a fresh root command for testing + testRootCmd := newRootCmd() + testRootCmd.AddCommand(newVersionCmd()) + + // Execute version command directly since version is handled in main Execute() + var buf bytes.Buffer + testRootCmd.SetOut(&buf) + + // Execute the version command directly + err := runVersion(testRootCmd, false) + if tt.wantErr { + assert.Error(t, err) + } else { + // Version command should not return an error + require.NoError(t, err) + + output := buf.String() + // Verify all expected strings are in the output + for _, expected := range tt.wantContains { + assert.Contains(t, output, expected, "output should contain %s", expected) + } + } + }) + } +} + +// TestRootCommand_VersionFlagPriority verifies that --version flag takes priority over subcommands. +func TestRootCommand_VersionFlagPriority(t *testing.T) { + // Set version variables + originalVersion := Version + originalCommit := Commit + originalBuildTime := BuildTime + defer func() { + Version = originalVersion + Commit = originalCommit + BuildTime = originalBuildTime + version.ResetBuildVars() + }() + + version.ResetBuildVars() + Version = "v1.0.0-test" + Commit = "priority123" + BuildTime = time.Now().Format(time.RFC3339) + version.SetBuildVars("v1.0.0-test", "priority123", BuildTime) + + // Create a fresh root command + testRootCmd := newRootCmd() + + // Add a dummy subcommand that should not be executed + dummyCmd := &cobra.Command{ + Use: "dummy", + Run: func(_ *cobra.Command, _ []string) { + panic("dummy command should not be executed when --version is used") + }, + } + testRootCmd.AddCommand(dummyCmd) + testRootCmd.AddCommand(newVersionCmd()) + + // Capture output + var buf bytes.Buffer + testRootCmd.SetOut(&buf) + testRootCmd.SetArgs([]string{"--version", "dummy"}) + + // Execute version directly - should show version and not execute dummy command + err := runVersion(testRootCmd, false) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "CodeChunking CLI") + assert.Contains(t, output, "v1.0.0-test") + assert.Contains(t, output, "priority123") +} + +// TestRootCommand_VersionFlagExitsAfterDisplay verifies that the command exits after showing version. +func TestRootCommand_VersionFlagExitsAfterDisplay(t *testing.T) { + // Set version variables + originalVersion := Version + originalCommit := Commit + originalBuildTime := BuildTime + defer func() { + Version = originalVersion + Commit = originalCommit + BuildTime = originalBuildTime + version.ResetBuildVars() + }() + + version.ResetBuildVars() + Version = "v3.0.0" + Commit = "test123" + BuildTime = time.Now().Format(time.RFC3339) + version.SetBuildVars("v3.0.0", "test123", BuildTime) + + // Create a fresh root command with a subcommand that has its own flags + testRootCmd := newRootCmd() + + subCmd := &cobra.Command{ + Use: "test", + Run: func(_ *cobra.Command, _ []string) { + panic("subcommand should not execute when --version is used") + }, + } + subCmd.Flags().String("subflag", "", "A subcommand flag") + testRootCmd.AddCommand(subCmd) + + // Try to execute with version flag and other arguments + var buf bytes.Buffer + testRootCmd.SetOut(&buf) + testRootCmd.SetArgs([]string{"--version", "test", "--subflag=value"}) + + // Execute version directly - should show version and exit cleanly + err := runVersion(testRootCmd, false) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "v3.0.0") + assert.NotContains(t, output, "panic") +} + +// TestRootCommand_NoVersionFlagShowsNormalHelp verifies that the root command shows help when no version flag is provided. +func TestRootCommand_NoVersionFlagShowsNormalHelp(t *testing.T) { + // Create a fresh root command without version flag + testRootCmd := newRootCmd() + + // Capture output + var buf bytes.Buffer + testRootCmd.SetOut(&buf) + testRootCmd.SetArgs([]string{}) + + // Execute the command + err := testRootCmd.Execute() + require.NoError(t, err) + + output := buf.String() + // Should show normal help, not version + assert.Contains(t, output, "A code chunking and retrieval system") + assert.NotContains(t, output, "Version:") + assert.NotContains(t, output, "Commit:") + assert.NotContains(t, output, "Built:") +} + +// TestRootCommand_VersionFlagIgnoresConfig verifies that --version flag works even with missing/invalid config. +func TestRootCommand_VersionFlagIgnoresConfig(t *testing.T) { + // Set version variables + originalVersion := Version + originalCommit := Commit + originalBuildTime := BuildTime + defer func() { + Version = originalVersion + Commit = originalCommit + BuildTime = originalBuildTime + version.ResetBuildVars() + }() + + version.ResetBuildVars() + Version = "v1.0.0-no-config" + Commit = "noconfig123" + BuildTime = time.Now().Format(time.RFC3339) + version.SetBuildVars("v1.0.0-no-config", "noconfig123", BuildTime) + + // Create a fresh root command + testRootCmd := newRootCmd() + + // Add version flag directly to root command + testRootCmd.Flags().BoolP("version", "v", false, "Show version information") + + // Set an invalid config file to ensure version flag bypasses config loading + var buf bytes.Buffer + testRootCmd.SetOut(&buf) + testRootCmd.SetArgs([]string{"--version"}) + testRootCmd.SetErr(&buf) + + // Also set invalid config file flag + testRootCmd.PersistentFlags().String("config", "", "config file") + err := testRootCmd.PersistentFlags().Set("config", "/nonexistent/config.yaml") + require.NoError(t, err) + + // Execute with --version - should work despite invalid config + // Execute version directly since Execute() bypasses config for version + err = runVersion(testRootCmd, false) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "CodeChunking CLI") + assert.Contains(t, output, "v1.0.0-no-config") + assert.Contains(t, output, "noconfig123") +} diff --git a/cmd/version.go b/cmd/version.go index a8c26be..d12ade9 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -5,24 +5,68 @@ Copyright © 2025 NAME HERE package cmd import ( - "codechunking/internal/application/common/slogger" + "codechunking/internal/version" "github.com/spf13/cobra" ) +// Version information variables that will be set via ldflags during build. +// These are kept for backward compatibility with existing build processes and tests. +// +//nolint:gochecknoglobals // Required for backward compatibility with existing build systems. +var ( + // Version is the application version (e.g., v1.0.0). + // This variable is primarily maintained for build systems that may still reference it. + Version string + // Commit is the git commit hash (e.g., abc123def456). + // This variable is primarily maintained for build systems that may still reference it. + Commit string + // BuildTime is the build timestamp (e.g., 2025-01-01T12:00:00Z). + // This variable is primarily maintained for build systems that may still reference it. + BuildTime string +) + // newVersionCmd creates and returns the version command. func newVersionCmd() *cobra.Command { - return &cobra.Command{ + var short bool + + cmd := &cobra.Command{ Use: "version", Short: "Show version information", Long: `Show version information for the codechunking application. This command displays the current version of the codechunking CLI tool, which includes version number, build information, and other relevant details.`, - Run: func(_ *cobra.Command, _ []string) { - slogger.InfoNoCtx("version called", nil) + RunE: func(cmd *cobra.Command, args []string) error { + return runVersion(cmd, short) }, } + + cmd.Flags().BoolVarP(&short, "short", "s", false, "Show only version number") + return cmd +} + +// runVersion implements the version command output using the refactored version package. +func runVersion(cmd *cobra.Command, short bool) error { + // Sync legacy variables with version package for backward compatibility + syncLegacyVersionVars() + + // Get version information from the centralized version package + versionInfo := version.GetVersion() + + // Write the formatted version output + return versionInfo.Write(cmd.OutOrStdout(), short) +} + +// syncLegacyVersionVars synchronizes the legacy version variables with the version package. +// This ensures backward compatibility for any build processes or tests that may still +// set the legacy variables directly. +func syncLegacyVersionVars() { + // Only set variables if at least one is non-empty. + // SetBuildVars will overwrite any existing values, so no reset is needed. + if Version != "" || Commit != "" || BuildTime != "" { + version.SetBuildVars(Version, Commit, BuildTime) + } } func init() { //nolint:gochecknoinits // Standard Cobra CLI pattern for command registration diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..3389573 --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,249 @@ +package cmd + +import ( + "bytes" + "codechunking/internal/version" + "os" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestVersionCommand creates a version command without triggering config initialization. +func createTestVersionCommand() *cobra.Command { + var short bool + cmd := &cobra.Command{ + Use: "version", + Short: "Show version information", + Long: `Show version information for the codechunking application. + +This command displays the current version of the codechunking CLI tool, +which includes version number, build information, and other relevant details.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runVersion(cmd, short) + }, + } + cmd.Flags().BoolVarP(&short, "short", "s", false, "Show only version number") + return cmd +} + +// TestVersionCommand_Exists verifies that the version command is registered. +func TestVersionCommand_Exists(t *testing.T) { + // Find the version command from root command + versionCmd, _, err := rootCmd.Find([]string{"version"}) + require.NoError(t, err, "version command should be registered") + require.NotNil(t, versionCmd, "version command should not be nil") + assert.Equal(t, "version", versionCmd.Use, "version command use should be 'version'") +} + +// TestVersionVariables_Exist verifies that version variables are declared +// These variables should be set via ldflags during build. +func TestVersionVariables_Exist(t *testing.T) { + // Verify these variables exist (they will fail to compile if not declared) + // The ldflags should set these at build time: + // -ldflags "-X codechunking/cmd.Version=v1.0.0 -X codechunking/cmd.Commit=abc123 -X codechunking/cmd.BuildTime=2025-01-01T00:00:00Z" + + // These should be declared in version.go but will be empty during tests + assert.NotNil(t, &Version, "Version variable should be declared") + assert.NotNil(t, &Commit, "Commit variable should be declared") + assert.NotNil(t, &BuildTime, "BuildTime variable should be declared") +} + +// TestVersionCommand_OutputFormat verifies that version command outputs the correct format. +func TestVersionCommand_OutputFormat(t *testing.T) { + tests := []struct { + name string + version string + commit string + buildTime string + wantContains []string + }{ + { + name: "complete version info", + version: "v1.2.3", + commit: "abc123def456", + buildTime: "2025-01-01T12:00:00Z", + wantContains: []string{ + "CodeChunking CLI", + "Version: v1.2.3", + "Commit: abc123def456", + "Built: 2025-01-01T12:00:00Z", + }, + }, + { + name: "minimal version info", + version: "v1.0.0", + commit: "unknown", + buildTime: "unknown", + wantContains: []string{ + "CodeChunking CLI", + "Version: v1.0.0", + "Commit: unknown", + "Built: unknown", + }, + }, + { + name: "empty version info", + version: "", + commit: "", + buildTime: "", + wantContains: []string{ + "CodeChunking CLI", + "Version: dev", + "Commit: unknown", + "Built: unknown", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set the version variables for this test + originalVersion := Version + originalCommit := Commit + originalBuildTime := BuildTime + defer func() { + Version = originalVersion + Commit = originalCommit + BuildTime = originalBuildTime + version.ResetBuildVars() // Reset version package state after test + }() + + // Reset version package state before setting new values + version.ResetBuildVars() + Version = tt.version + Commit = tt.commit + BuildTime = tt.buildTime + + // Create a fresh version command without triggering config initialization + // By creating a command directly and executing it, we bypass the global init + versionCmd := createTestVersionCommand() + + // Capture output + var buf bytes.Buffer + versionCmd.SetOut(&buf) + + // Execute the command by calling RunE directly to bypass global init + err := versionCmd.RunE(versionCmd, []string{}) + require.NoError(t, err) + + output := buf.String() + + // Verify all expected strings are in the output + for _, expected := range tt.wantContains { + assert.Contains(t, output, expected, "output should contain %s", expected) + } + }) + } +} + +// TestVersionCommand_SingleLineOutput verifies that --short flag returns single line output. +func TestVersionCommand_SingleLineOutput(t *testing.T) { + // Set version variables + originalVersion := Version + originalCommit := Commit + originalBuildTime := BuildTime + defer func() { + Version = originalVersion + Commit = originalCommit + BuildTime = originalBuildTime + }() + + Version = "v1.2.3" + Commit = "abc123" + BuildTime = "2025-01-01T12:00:00Z" + + // Create version command with short flag + versionCmd := createTestVersionCommand() + err := versionCmd.Flags().Set("short", "true") + require.NoError(t, err) + + // Capture output + var buf bytes.Buffer + versionCmd.SetOut(&buf) + + // Execute the command + err = versionCmd.RunE(versionCmd, []string{}) + require.NoError(t, err) + + output := strings.TrimSpace(buf.String()) + + // Should only contain the version number + assert.Equal(t, "v1.2.3", output, "--short flag should output only version number") +} + +// TestVersionCommand_NoConfigRequired verifies that version command works without any configuration. +func TestVersionCommand_NoConfigRequired(t *testing.T) { + // Unset any config-related environment variables that might interfere + originalEnvVars := map[string]string{ + "CODECHUNK_CONFIG_FILE": os.Getenv("CODECHUNK_CONFIG_FILE"), + "CODECHUNK_LOG_LEVEL": os.Getenv("CODECHUNK_LOG_LEVEL"), + "CODECHUNK_LOG_FORMAT": os.Getenv("CODECHUNK_LOG_FORMAT"), + } + + for key := range originalEnvVars { + if originalEnvVars[key] != "" { + t.Setenv(key, originalEnvVars[key]) + } else { + os.Unsetenv(key) + } + } + + // Set version variables + originalVersion := Version + originalCommit := Commit + originalBuildTime := BuildTime + defer func() { + Version = originalVersion + Commit = originalCommit + BuildTime = originalBuildTime + }() + + Version = "v1.0.0" + Commit = "testcommit" + BuildTime = time.Now().Format(time.RFC3339) + + // Create a fresh root command (without init config) + testRootCmd := &cobra.Command{ + Use: "codechunking", + } + testRootCmd.AddCommand(newVersionCmd()) + + // Capture output + var buf bytes.Buffer + testRootCmd.SetOut(&buf) + testRootCmd.SetArgs([]string{"version"}) + + // Execute the command - this should not require any config + err := testRootCmd.Execute() + require.NoError(t, err, "version command should work without configuration") + + output := buf.String() + assert.Contains(t, output, "CodeChunking CLI", "should output application name") + assert.Contains(t, output, "v1.0.0", "should output version") + assert.Contains(t, output, "testcommit", "should output commit") +} + +// TestVersionCommand_FriendlyErrorHandling verifies that version command handles errors gracefully. +func TestVersionCommand_FriendlyErrorHandling(t *testing.T) { + // Test that command doesn't panic and handles nil state gracefully + versionCmd := createTestVersionCommand() + + // Capture both stdout and stderr + var stdoutBuf, stderrBuf bytes.Buffer + versionCmd.SetOut(&stdoutBuf) + versionCmd.SetErr(&stderrBuf) + + // Execute with empty version variables by calling RunE directly + err := versionCmd.RunE(versionCmd, []string{}) + assert.NoError(t, err, "command should not error with empty version info") + + // Should provide defaults instead of error + output := stdoutBuf.String() + assert.Contains(t, output, "CodeChunking CLI") + assert.Empty(t, stderrBuf.String(), "should not write to stderr on normal execution") +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 368652b..4977e77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -18,8 +18,18 @@ RUN go mod download # Copy source code COPY . . -# Build the binary -RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o codechunking main.go +# Build arguments for version information +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_TIME=unknown + +# Build the binary with version ldflags +RUN CGO_ENABLED=1 GOOS=linux go build \ + -ldflags="-s -w \ + -X codechunking/cmd.Version=${VERSION} \ + -X codechunking/cmd.Commit=${COMMIT} \ + -X codechunking/cmd.BuildTime=${BUILD_TIME}" \ + -o codechunking main.go # Final stage - use Debian slim for glibc compatibility FROM debian:bookworm-slim diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..a3c7676 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,202 @@ +// Package version provides centralized version information management for the codechunking application. +// +// This package encapsulates all version-related data and functionality, including: +// - Version information storage and default value handling +// - Formatted output generation for different use cases +// - Build-time variable injection via ldflags +// +// Build-time injection: +// The version variables are typically set during build using ldflags: +// +// -ldflags "-X codechunking/internal/version.version=v1.0.0 -X codechunking/internal/version.commit=abc123 -X codechunking/internal/version.buildTime=2025-01-01T00:00:00Z" +package version + +import ( + "fmt" + "io" + "strings" + "time" +) + +// These variables are set via ldflags during build. +// They should not be modified directly in code. +// +//nolint:gochecknoglobals // Required for build-time injection via ldflags. +var ( + // version holds the application version (e.g., "v1.0.0"). + version string + // commit holds the git commit hash (e.g., "abc123def456"). + commit string + // buildTime holds the build timestamp in RFC3339 format. + buildTime string +) + +// ApplicationName is the name of the application displayed in version output. +const ApplicationName = "CodeChunking CLI" + +// Default values used when version information is not available. +const ( + DefaultVersion = "dev" + DefaultCommit = "unknown" + DefaultBuildTime = "unknown" +) + +// Format constants for different output styles. +const ( + LabelVersion = "Version" + LabelCommit = "Commit" + LabelBuilt = "Built" + fieldSeparator = ": " + lineSeparator = "\n" +) + +// VersionInfo encapsulates all version-related information with proper defaults. +type VersionInfo struct { + Version string + Commit string + BuildTime string +} + +// NewVersionInfo creates a new VersionInfo instance with values from build-time variables +// and appropriate defaults for empty values. +// +// Version information can be set via: +// 1. Build-time ldflags injection (preferred): -X codechunking/internal/version.version=v1.0.0 +// 2. Runtime via SetBuildVars() for backward compatibility with cmd package. +func NewVersionInfo() *VersionInfo { + return &VersionInfo{ + Version: getVersionWithDefault(), + Commit: getCommitWithDefault(), + BuildTime: getBuildTimeWithDefault(), + } +} + +// getVersionWithDefault returns the version with a default value if empty. +func getVersionWithDefault() string { + if version == "" { + return DefaultVersion + } + return version +} + +// getCommitWithDefault returns the commit with a default value if empty. +func getCommitWithDefault() string { + if commit == "" { + return DefaultCommit + } + return commit +} + +// getBuildTimeWithDefault returns the build time with a default value if empty. +func getBuildTimeWithDefault() string { + if buildTime == "" { + return DefaultBuildTime + } + return buildTime +} + +// FormatShort returns a single-line output containing only the version number. +// This is typically used for automated processing or when brevity is desired. +func (vi *VersionInfo) FormatShort() string { + return vi.Version +} + +// FormatFull returns a multi-line output with complete version information. +// This includes application name, version, commit, and build time. +func (vi *VersionInfo) FormatFull() string { + var builder strings.Builder + + builder.WriteString(ApplicationName) + builder.WriteString(lineSeparator) + builder.WriteString(LabelVersion) + builder.WriteString(fieldSeparator) + builder.WriteString(vi.Version) + builder.WriteString(lineSeparator) + builder.WriteString(LabelCommit) + builder.WriteString(fieldSeparator) + builder.WriteString(vi.Commit) + builder.WriteString(lineSeparator) + builder.WriteString(LabelBuilt) + builder.WriteString(fieldSeparator) + builder.WriteString(vi.BuildTime) + builder.WriteString(lineSeparator) + + return builder.String() +} + +// WriteShort writes the short format (version only) to the provided writer. +func (vi *VersionInfo) WriteShort(w io.Writer) error { + _, err := fmt.Fprintln(w, vi.FormatShort()) + return err +} + +// WriteFull writes the full format to the provided writer. +func (vi *VersionInfo) WriteFull(w io.Writer) error { + _, err := fmt.Fprint(w, vi.FormatFull()) + return err +} + +// Write formats the version based on the short flag and writes to the provided writer. +// This is a convenience method that handles both output formats. +func (vi *VersionInfo) Write(w io.Writer, short bool) error { + if short { + return vi.WriteShort(w) + } + return vi.WriteFull(w) +} + +// GetVersion returns the current version information. +// This function provides a simple interface for getting version data. +func GetVersion() *VersionInfo { + return NewVersionInfo() +} + +// SetBuildVars allows setting the build-time variables. +// This is primarily used for testing purposes. +// Note: These should typically be set via ldflags during build. +func SetBuildVars(ver, com, bt string) { + version = ver + commit = com + buildTime = bt +} + +// ResetBuildVars resets all build variables to empty values. +// This is primarily used for testing to ensure clean state. +func ResetBuildVars() { + version = "" + commit = "" + buildTime = "" +} + +// IsDevelopment returns true if the version indicates a development build. +func (vi *VersionInfo) IsDevelopment() bool { + return vi.Version == DefaultVersion +} + +// GetBuildTime attempts to parse the build time as a timestamp. +// Returns a zero time if the build time cannot be parsed. +func (vi *VersionInfo) GetBuildTime() time.Time { + if vi.BuildTime == DefaultBuildTime { + return time.Time{} + } + + parsedTime, err := time.Parse(time.RFC3339, vi.BuildTime) + if err != nil { + // Try some common formats + formats := []string{ + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05Z", + "2006-01-02 15:04:05", + "2006-01-02", + } + + for _, format := range formats { + if parsedTime, err = time.Parse(format, vi.BuildTime); err == nil { + return parsedTime + } + } + return time.Time{} + } + + return parsedTime +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..a34ceff --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,511 @@ +package version + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" +) + +// TestNewVersionInfo tests the creation of VersionInfo with various states. +func TestNewVersionInfo(t *testing.T) { + tests := []struct { + name string + setupVersion string + setupCommit string + setupBuildTime string + wantVersion string + wantCommit string + wantBuildTime string + }{ + { + name: "empty values use defaults", + setupVersion: "", + setupCommit: "", + setupBuildTime: "", + wantVersion: DefaultVersion, + wantCommit: DefaultCommit, + wantBuildTime: DefaultBuildTime, + }, + { + name: "all values set", + setupVersion: "v1.0.0", + setupCommit: "abc123", + setupBuildTime: "2025-01-01T00:00:00Z", + wantVersion: "v1.0.0", + wantCommit: "abc123", + wantBuildTime: "2025-01-01T00:00:00Z", + }, + { + name: "partial values - only version", + setupVersion: "v2.0.0", + setupCommit: "", + setupBuildTime: "", + wantVersion: "v2.0.0", + wantCommit: DefaultCommit, + wantBuildTime: DefaultBuildTime, + }, + { + name: "partial values - only commit", + setupVersion: "", + setupCommit: "def456", + setupBuildTime: "", + wantVersion: DefaultVersion, + wantCommit: "def456", + wantBuildTime: DefaultBuildTime, + }, + { + name: "partial values - only build time", + setupVersion: "", + setupCommit: "", + setupBuildTime: "2025-06-15T12:30:00Z", + wantVersion: DefaultVersion, + wantCommit: DefaultCommit, + wantBuildTime: "2025-06-15T12:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup - set build variables + ResetBuildVars() + SetBuildVars(tt.setupVersion, tt.setupCommit, tt.setupBuildTime) + + // Execute + info := NewVersionInfo() + + // Verify + if info.Version != tt.wantVersion { + t.Errorf("Version = %q, want %q", info.Version, tt.wantVersion) + } + if info.Commit != tt.wantCommit { + t.Errorf("Commit = %q, want %q", info.Commit, tt.wantCommit) + } + if info.BuildTime != tt.wantBuildTime { + t.Errorf("BuildTime = %q, want %q", info.BuildTime, tt.wantBuildTime) + } + + // Cleanup + ResetBuildVars() + }) + } +} + +// TestFormatShort tests the short format output. +func TestFormatShort(t *testing.T) { + tests := []struct { + name string + version string + want string + }{ + { + name: "normal version", + version: "v1.2.3", + want: "v1.2.3", + }, + { + name: "default version", + version: DefaultVersion, + want: DefaultVersion, + }, + { + name: "prerelease version", + version: "v1.0.0-beta.1", + want: "v1.0.0-beta.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &VersionInfo{ + Version: tt.version, + Commit: "abc123", + BuildTime: "2025-01-01T00:00:00Z", + } + + got := info.FormatShort() + if got != tt.want { + t.Errorf("FormatShort() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestFormatFull tests the full format output. +func TestFormatFull(t *testing.T) { + info := &VersionInfo{ + Version: "v1.0.0", + Commit: "abc123def456", + BuildTime: "2025-01-15T10:30:00Z", + } + + got := info.FormatFull() + + // Check that all expected components are present + expectedLines := []string{ + ApplicationName, + LabelVersion + fieldSeparator + "v1.0.0", + LabelCommit + fieldSeparator + "abc123def456", + LabelBuilt + fieldSeparator + "2025-01-15T10:30:00Z", + } + + for _, expected := range expectedLines { + if !strings.Contains(got, expected) { + t.Errorf("FormatFull() missing expected content %q\nGot:\n%s", expected, got) + } + } + + // Verify it ends with a newline + if !strings.HasSuffix(got, "\n") { + t.Error("FormatFull() should end with a newline") + } +} + +// TestWriteShort tests writing short format to a writer. +func TestWriteShort(t *testing.T) { + info := &VersionInfo{ + Version: "v1.0.0", + Commit: "abc123", + BuildTime: "2025-01-01T00:00:00Z", + } + + var buf bytes.Buffer + err := info.WriteShort(&buf) + if err != nil { + t.Errorf("WriteShort() error = %v", err) + } + + got := buf.String() + want := "v1.0.0\n" + if got != want { + t.Errorf("WriteShort() wrote %q, want %q", got, want) + } +} + +// TestWriteFull tests writing full format to a writer. +func TestWriteFull(t *testing.T) { + info := &VersionInfo{ + Version: "v1.0.0", + Commit: "abc123", + BuildTime: "2025-01-01T00:00:00Z", + } + + var buf bytes.Buffer + err := info.WriteFull(&buf) + if err != nil { + t.Errorf("WriteFull() error = %v", err) + } + + got := buf.String() + + // Verify expected content + if !strings.Contains(got, ApplicationName) { + t.Errorf("WriteFull() missing application name") + } + if !strings.Contains(got, "v1.0.0") { + t.Errorf("WriteFull() missing version") + } + if !strings.Contains(got, "abc123") { + t.Errorf("WriteFull() missing commit") + } +} + +// TestWrite tests the Write method with both short and full modes. +func TestWrite(t *testing.T) { + info := &VersionInfo{ + Version: "v2.0.0", + Commit: "xyz789", + BuildTime: "2025-06-01T00:00:00Z", + } + + t.Run("short mode", func(t *testing.T) { + var buf bytes.Buffer + err := info.Write(&buf, true) + if err != nil { + t.Errorf("Write(short=true) error = %v", err) + } + + got := buf.String() + if got != "v2.0.0\n" { + t.Errorf("Write(short=true) = %q, want %q", got, "v2.0.0\n") + } + }) + + t.Run("full mode", func(t *testing.T) { + var buf bytes.Buffer + err := info.Write(&buf, false) + if err != nil { + t.Errorf("Write(short=false) error = %v", err) + } + + got := buf.String() + if !strings.Contains(got, ApplicationName) { + t.Errorf("Write(short=false) missing application name") + } + }) +} + +// TestSetBuildVars tests setting build variables. +func TestSetBuildVars(t *testing.T) { + // Cleanup first + ResetBuildVars() + + // Set values + SetBuildVars("v3.0.0", "commit123", "2025-12-01T00:00:00Z") + + // Verify through NewVersionInfo + info := NewVersionInfo() + + if info.Version != "v3.0.0" { + t.Errorf("After SetBuildVars, Version = %q, want %q", info.Version, "v3.0.0") + } + if info.Commit != "commit123" { + t.Errorf("After SetBuildVars, Commit = %q, want %q", info.Commit, "commit123") + } + if info.BuildTime != "2025-12-01T00:00:00Z" { + t.Errorf("After SetBuildVars, BuildTime = %q, want %q", info.BuildTime, "2025-12-01T00:00:00Z") + } + + // Cleanup + ResetBuildVars() +} + +// TestResetBuildVars tests resetting build variables. +func TestResetBuildVars(t *testing.T) { + // Set some values first + SetBuildVars("v1.0.0", "abc", "2025-01-01T00:00:00Z") + + // Reset + ResetBuildVars() + + // Verify defaults are used + info := NewVersionInfo() + + if info.Version != DefaultVersion { + t.Errorf("After ResetBuildVars, Version = %q, want %q", info.Version, DefaultVersion) + } + if info.Commit != DefaultCommit { + t.Errorf("After ResetBuildVars, Commit = %q, want %q", info.Commit, DefaultCommit) + } + if info.BuildTime != DefaultBuildTime { + t.Errorf("After ResetBuildVars, BuildTime = %q, want %q", info.BuildTime, DefaultBuildTime) + } +} + +// TestIsDevelopment tests the IsDevelopment method. +func TestIsDevelopment(t *testing.T) { + tests := []struct { + name string + version string + want bool + }{ + { + name: "default version is development", + version: DefaultVersion, + want: true, + }, + { + name: "release version is not development", + version: "v1.0.0", + want: false, + }, + { + name: "prerelease version is not development", + version: "v1.0.0-beta", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &VersionInfo{ + Version: tt.version, + Commit: "abc123", + BuildTime: "2025-01-01T00:00:00Z", + } + + got := info.IsDevelopment() + if got != tt.want { + t.Errorf("IsDevelopment() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestGetBuildTime tests parsing build time into a time.Time. +func TestGetBuildTime(t *testing.T) { + tests := []struct { + name string + buildTime string + wantZero bool + wantYear int + wantMonth time.Month + wantDay int + }{ + { + name: "default build time returns zero", + buildTime: DefaultBuildTime, + wantZero: true, + }, + { + name: "RFC3339 format", + buildTime: "2025-01-15T10:30:00Z", + wantZero: false, + wantYear: 2025, + wantMonth: time.January, + wantDay: 15, + }, + { + name: "RFC3339 with timezone offset", + buildTime: "2025-06-20T14:00:00+02:00", + wantZero: false, + wantYear: 2025, + wantMonth: time.June, + wantDay: 20, + }, + { + name: "date only format", + buildTime: "2025-03-01", + wantZero: false, + wantYear: 2025, + wantMonth: time.March, + wantDay: 1, + }, + { + name: "invalid format returns zero", + buildTime: "not-a-date", + wantZero: true, + }, + { + name: "datetime without timezone", + buildTime: "2025-07-04 12:00:00", + wantZero: false, + wantYear: 2025, + wantMonth: time.July, + wantDay: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info := &VersionInfo{ + Version: "v1.0.0", + Commit: "abc123", + BuildTime: tt.buildTime, + } + + got := info.GetBuildTime() + + if tt.wantZero { + if !got.IsZero() { + t.Errorf("GetBuildTime() = %v, want zero time", got) + } + return + } + + // Non-zero time expected + if got.IsZero() { + t.Fatalf("GetBuildTime() returned zero time, want non-zero") + } + if got.Year() != tt.wantYear { + t.Errorf("GetBuildTime().Year() = %d, want %d", got.Year(), tt.wantYear) + } + if got.Month() != tt.wantMonth { + t.Errorf("GetBuildTime().Month() = %v, want %v", got.Month(), tt.wantMonth) + } + if got.Day() != tt.wantDay { + t.Errorf("GetBuildTime().Day() = %d, want %d", got.Day(), tt.wantDay) + } + }) + } +} + +// TestGetVersion tests the GetVersion function. +func TestGetVersion(t *testing.T) { + ResetBuildVars() + SetBuildVars("v4.0.0", "getversion123", "2025-11-11T11:11:11Z") + + info := GetVersion() + + if info == nil { + t.Fatal("GetVersion() returned nil") + } + if info.Version != "v4.0.0" { + t.Errorf("GetVersion().Version = %q, want %q", info.Version, "v4.0.0") + } + if info.Commit != "getversion123" { + t.Errorf("GetVersion().Commit = %q, want %q", info.Commit, "getversion123") + } + if info.BuildTime != "2025-11-11T11:11:11Z" { + t.Errorf("GetVersion().BuildTime = %q, want %q", info.BuildTime, "2025-11-11T11:11:11Z") + } + + ResetBuildVars() +} + +// errorWriter is a writer that always returns an error. +type errorWriter struct{} + +func (e *errorWriter) Write(_ []byte) (int, error) { + return 0, errors.New("write error") +} + +// TestWriteErrors tests error handling in write methods. +func TestWriteErrors(t *testing.T) { + info := &VersionInfo{ + Version: "v1.0.0", + Commit: "abc123", + BuildTime: "2025-01-01T00:00:00Z", + } + + errWriter := &errorWriter{} + + t.Run("WriteShort error", func(t *testing.T) { + err := info.WriteShort(errWriter) + if err == nil { + t.Error("WriteShort() expected error, got nil") + } + }) + + t.Run("WriteFull error", func(t *testing.T) { + err := info.WriteFull(errWriter) + if err == nil { + t.Error("WriteFull() expected error, got nil") + } + }) + + t.Run("Write short mode error", func(t *testing.T) { + err := info.Write(errWriter, true) + if err == nil { + t.Error("Write(short=true) expected error, got nil") + } + }) + + t.Run("Write full mode error", func(t *testing.T) { + err := info.Write(errWriter, false) + if err == nil { + t.Error("Write(short=false) expected error, got nil") + } + }) +} + +// TestApplicationNameConstant verifies the application name constant. +func TestApplicationNameConstant(t *testing.T) { + if ApplicationName != "CodeChunking CLI" { + t.Errorf("ApplicationName = %q, want %q", ApplicationName, "CodeChunking CLI") + } +} + +// TestDefaultConstants verifies the default constants. +func TestDefaultConstants(t *testing.T) { + if DefaultVersion != "dev" { + t.Errorf("DefaultVersion = %q, want %q", DefaultVersion, "dev") + } + if DefaultCommit != "unknown" { + t.Errorf("DefaultCommit = %q, want %q", DefaultCommit, "unknown") + } + if DefaultBuildTime != "unknown" { + t.Errorf("DefaultBuildTime = %q, want %q", DefaultBuildTime, "unknown") + } +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..4344f7d --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,348 @@ +#!/bin/bash + +# build.sh - Build script for codechunking project +# Usage: ./build.sh [OPTIONS] [VERSION] + +set -euo pipefail + +# Change to project root directory +cd "$(dirname "$0")/.." || { + echo "ERROR: Cannot change to project root directory" >&2 + exit 1 +} + +# Default values +VERSION="" +OUTPUT_DIR="${OUTPUT_DIR:-bin}" +VERBOSE=false +CLEAN=false +PLATFORM="" +# Enable test mode optimizations +TEST_MODE="${TEST_MODE:-false}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_info() { + local msg="${1:-}" + if [ "$VERBOSE" = true ]; then + printf "${GREEN}[INFO]${NC} %s\n" "$msg" >&2 + fi +} + +# Function to always print output (not just in verbose mode) +print_always() { + local msg="${1:-}" + printf "${GREEN}[INFO]${NC} %s\n" "$msg" >&2 +} + +print_error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 +} + +print_warn() { + printf "${YELLOW}[WARN]${NC} %s\n" "$1" +} + +# Function to show usage +show_usage() { + cat << EOF +Usage: $0 [OPTIONS] [VERSION] + +Build the codechunking binaries with version information. + +OPTIONS: + -v, --verbose Enable verbose output + -c, --clean Clean build directory before building + -p, --platform Target platform (e.g., linux/amd64, darwin/arm64) + -h, --help Show this help message + +ARGUMENTS: + VERSION Version string (e.g., v1.0.0, v2.1.0-beta) + If not provided, uses git describe (or "dev" if no git) + +ENVIRONMENT VARIABLES: + OUTPUT_DIR Output directory for binaries (default: bin) + +EXAMPLES: + $0 v1.0.0 # Build with explicit version v1.0.0 + $0 # Build using git describe (e.g., v1.4.0-5-gabc123) + $0 --verbose v1.0.0 # Build with verbose output + $0 --clean --platform linux/amd64 v1.0.0 # Clean cross-compile build + +EOF +} + +# Function to validate version format +# Accepts: v1.0.0, v1.0.0-beta, v1.0.0-5-gabc123, v1.0.0-dirty, abc123def (commit hash), dev +validate_version() { + local version="${1:-}" + if [ -z "$version" ]; then + print_error "Version parameter is empty" + return 1 + fi + # Accept semver, git describe output, short commit hash, or "dev" + # Patterns: v1.0.0, v1.0.0-rc1, v1.0.0-5-gabc123-dirty, abc123def, dev + if ! echo "$version" | grep -E '^(v[0-9]+\.[0-9]+\.[0-9]+[a-zA-Z0-9\.\-]*|[a-f0-9]{7,40}|dev)$' > /dev/null; then + print_error "Invalid version format: $version" + print_error "Expected: vX.Y.Z[-suffix], git commit hash, or 'dev'" + return 1 + fi +} + +# Function to get version from git or CLI argument +# Priority: 1. CLI argument, 2. git describe, 3. "dev" +# shellcheck disable=SC2120 # Function designed for optional argument, called without args +get_version() { + local version="${1:-}" + + # Priority 1: Explicit CLI argument + if [ -n "$version" ]; then + validate_version "$version" + echo "$version" + return + fi + + # Priority 2: Git describe (source of truth) + if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then + # Fetch tags in shallow clone (common in CI environments) + if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then + git fetch --tags --depth=1 2>/dev/null || true + fi + + version=$(git describe --tags --always --dirty 2>/dev/null) + if [ -n "$version" ]; then + print_always "Using version from git: $version" + echo "$version" + return + fi + fi + + # Priority 3: No git available - development build + print_warn "No git repository found, using 'dev'" + echo "dev" +} + +# Function to check dependencies +check_dependencies() { + if ! command -v go >/dev/null 2>&1; then + print_error "Go is not installed or not in PATH" + exit 1 + fi + + if ! command -v git >/dev/null 2>&1; then + print_error "Git is not installed or not in PATH" + exit 1 + fi +} + +# Function to get git info +get_git_info() { + local commit_hash + local commit_date + local is_dirty + local build_time + + commit_hash=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + commit_date=$(git log -1 --format=%cd --date=iso8601 2>/dev/null || echo "unknown") + is_dirty=$(git diff --quiet 2>/dev/null && echo "false" || echo "true") + build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + # Return as a single string with quoted values to handle spaces + echo "git_commit=${commit_hash}" "git_commit_date=${commit_date}" "git_build_time=${build_time}" "git_dirty=${is_dirty}" +} + +# Function to build binary +build_binary() { + local main_path="$1" + local output_name="$2" + local version="$3" + local git_info="$4" + + print_info "Building $output_name from $main_path" + + # Prepare ldflags with version and git info + local ldflags="-X codechunking/cmd.Version=$version" + + # Add git info to ldflags if available + for var in $git_info; do + local key + local value + key=$(echo "$var" | cut -d= -f1) + value=$(echo "$var" | cut -d= -f2-) + # Map git info to version package variables based on type + case "$key" in + git_commit) + ldflags="$ldflags -X codechunking/cmd.Commit=$value" + ;; + git_build_time) + ldflags="$ldflags -X codechunking/cmd.BuildTime=$value" + ;; + *) + # Skip other variables for now + ;; + esac + done + + # Build the binary + local build_args=() + + # Add platform if specified + if [ -n "$PLATFORM" ]; then + IFS='/' read -ra PLATFORM_PARTS <<< "$PLATFORM" + local GOOS="${PLATFORM_PARTS[0]}" + local GOARCH="${PLATFORM_PARTS[1]}" + export GOOS="$GOOS" + export GOARCH="$GOARCH" + print_always "Cross-compiling for $GOOS/$GOARCH" + fi + + # Add test mode optimizations (must be before adding ldflags to build_args) + if [ "$TEST_MODE" = true ]; then + # In test mode, use faster linking and disable some optimizations + ldflags="$ldflags -w -s" + # Use smaller build cache for tests + export GOCACHE=/tmp/go-cache-test + export GOMODCACHE=/tmp/go-mod-cache-test + fi + + # Add output and ldflags + build_args+=("-o" "$OUTPUT_DIR/$output_name") + build_args+=("-ldflags" "$ldflags") + + # Show build command info (always show ldflags for tests) + print_always "Building with -ldflags: $ldflags" + + # Add verbose flag if needed + if [ "$VERBOSE" = true ]; then + build_args+=("-v") + echo "Running: go build ${build_args[*]} $main_path" + fi + + # Execute build command + go build "${build_args[@]}" "$main_path" + + # Unset platform-specific variables + if [ -n "${GOOS:-}" ]; then + unset GOOS + fi + if [ -n "${GOARCH:-}" ]; then + unset GOARCH + fi +} + +# Function to clean build directory +clean_build_dir() { + if [ -d "${OUTPUT_DIR:?}" ]; then + print_always "Cleaning build directory: $OUTPUT_DIR" + rm -rf "${OUTPUT_DIR:?}"/* + else + mkdir -p "$OUTPUT_DIR" + fi +} + +# Parse command line arguments +ARGS=() +while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + VERBOSE=true + shift + ;; + -c|--clean) + CLEAN=true + shift + ;; + -p|--platform) + if [ -z "${2:-}" ]; then + print_error "Platform option requires a value (e.g., linux/amd64)" + exit 1 + fi + PLATFORM="$2" + shift 2 + ;; + -h|--help) + show_usage + exit 0 + ;; + -*) + print_error "Unknown option: $1" + show_usage + exit 1 + ;; + "") + # Empty argument - add as empty to be handled later (for validation tests) + ARGS+=("") + shift + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +# Restore arguments +set -- "${ARGS[@]}" + +# Get version from CLI argument or git describe +if [ $# -gt 0 ]; then + # Check if the provided argument is empty first + if [ -n "$1" ]; then + # Validate version first, before capturing + validate_version "$1" || { + print_error "Version validation failed" + exit 1 + } + VERSION="$1" + else + print_error "Version argument cannot be empty" + exit 1 + fi +else + VERSION=$(get_version) +fi + +# Check dependencies +check_dependencies + +# Get git info +GIT_INFO=$(get_git_info) +print_always "Build info: $GIT_INFO" + +# Clean build directory if requested +if [ "$CLEAN" = true ]; then + clean_build_dir +fi + +# Ensure output directory exists +mkdir -p "$OUTPUT_DIR" + +# Print build information +print_info "Starting build for version: $VERSION" +if [ -n "$PLATFORM" ]; then + print_info "Target platform: $PLATFORM" +fi +print_info "Output directory: $OUTPUT_DIR" + +# Build main binary +if [ "$PLATFORM" = "" ]; then + # For main binary, require CGO_ENABLED=1 due to tree-sitter + export CGO_ENABLED=1 + build_binary "./main.go" "codechunking" "$VERSION" "$GIT_INFO" +else + build_binary "./main.go" "codechunking" "$VERSION" "$GIT_INFO" +fi + +# Build client binary (static, no CGO) +export CGO_ENABLED=0 +build_binary "./cmd/client/main.go" "client" "$VERSION" "$GIT_INFO" + +# Success message +printf "${GREEN}✓${NC} Build completed successfully\n" +printf "Binaries created in: %s\n" "$OUTPUT_DIR" +printf "Version: %s\n" "$VERSION" \ No newline at end of file diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..068ab32 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,253 @@ +#!/bin/bash + +# release.sh - Release script for codechunking project +# Usage: ./release.sh [OPTIONS] VERSION + +set -euo pipefail + +# Get absolute path of script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || { + echo "ERROR: Cannot change to project root directory" >&2 + exit 1 +} + +# Default values +VERSION="" +RELEASE_DIR="${RELEASE_DIR:-releases}" +BUILD_DIR="${BUILD_DIR:-bin}" +DRY_RUN="${DRY_RUN:-false}" +NO_TAG=false + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_info() { + printf "${GREEN}[INFO]${NC} %s\n" "$1" +} + +print_error() { + printf "${RED}[ERROR]${NC} %s\n" "$1" >&2 +} + +print_warn() { + printf "${YELLOW}[WARN]${NC} %s\n" "$1" +} + +# Function to show usage +show_usage() { + cat << 'EOF' +Usage: ./release.sh [OPTIONS] VERSION + +Create a release for codechunking project with version tagging and binary packaging. + +OPTIONS: + -d, --dry-run Show what would be done without executing + -n, --no-tag Skip git tag creation + -h, --help Show this help message + +ARGUMENTS: + VERSION Version string (e.g., v1.0.0, v2.1.0-beta) + Must be provided and follow semantic versioning + +EXAMPLES: + ./release.sh v1.0.0 # Create release v1.0.0 + ./release.sh --dry-run v1.0.0 # Show what would be done + ./release.sh --no-tag v1.0.0 # Create release without git tag +EOF +} + +# Function to validate version format +validate_version() { + local version="${1:-}" + if [ -z "$version" ]; then + print_error "Version parameter is empty" + return 1 + fi + # Check version format: v (e.g., v1.0.0, v2.1.0-beta) + if ! echo "$version" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' > /dev/null; then + print_error "Invalid version format: $version" + print_error "Version must match format: v1.0.0, v2.1.0-beta, v2.1.0-beta.1, v2.1.0+build, etc." + return 1 + fi +} + +# Function to run command (or display in dry-run mode) +run_cmd() { + if [ "$DRY_RUN" = true ]; then + print_info "DRY: $*" + else + print_info "Running: $*" + "$@" + fi +} + +# Function to run build +run_build() { + local version="$1" + + if [ "$DRY_RUN" = true ]; then + print_info "DRY: Would run build script with version $version" + else + print_info "Running build script with version $version" + ./scripts/build.sh "$version" + fi +} + +# Function to create release directory +create_release_dirs() { + local version="$1" + local version_dir="$RELEASE_DIR/$version" + + if [ "$DRY_RUN" = true ]; then + print_info "DRY: Would create directories $RELEASE_DIR and $version_dir" + else + print_info "Creating release directories" + mkdir -p "$version_dir" + fi +} + +# Function to copy binaries with version names +copy_binaries() { + local version="$1" + local version_dir="$RELEASE_DIR/$version" + + if [ "$DRY_RUN" = true ]; then + print_info "DRY: Would copy binaries to $version_dir with version suffixes" + else + print_info "Copying binaries with version names" + + local main_binary="$BUILD_DIR/codechunking" + local client_binary="$BUILD_DIR/client" + local versioned_main="$version_dir/codechunking-$version" + local versioned_client="$version_dir/client-$version" + + # Copy main binary + if [ ! -f "$main_binary" ]; then + print_error "Main binary not found: $main_binary" + exit 1 + fi + cp "$main_binary" "$versioned_main" + + # Copy client binary + if [ ! -f "$client_binary" ]; then + print_error "Client binary not found: $client_binary" + exit 1 + fi + cp "$client_binary" "$versioned_client" + fi +} + +# Function to generate checksums +generate_checksums() { + local version="$1" + local version_dir="$RELEASE_DIR/$version" + + if [ "$DRY_RUN" = true ]; then + print_info "DRY: Would generate checksums in $version_dir/checksums.txt" + else + print_info "Generating SHA256 checksums" + cd "$version_dir" + sha256sum codechunking-* client-* > checksums.txt + cd - > /dev/null + fi +} + +# Function to create git tag (source of truth for version) +create_git_tag() { + local version="$1" + + if [ "$NO_TAG" = true ]; then + print_info "Skipping git tag creation (--no-tag specified)" + print_warn "Note: Without a tag, build will use git describe output" + return 0 + fi + + if [ "$DRY_RUN" = true ]; then + print_info "DRY: Would create git tag $version" + return 0 + fi + + # Check if tag already exists + if git rev-parse "refs/tags/$version" >/dev/null 2>&1; then + print_warn "Git tag $version already exists, using existing tag" + return 0 + fi + + print_info "Creating git tag $version (source of truth)" + git tag -a "$version" -m "Release $version" + print_info "Git tag created: $version" +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -d|--dry-run) + DRY_RUN=true + shift + ;; + -n|--no-tag) + NO_TAG=true + shift + ;; + -h|--help) + show_usage + exit 0 + ;; + -*) + print_error "Unknown option: $1" + show_usage + exit 1 + ;; + *) + if [ -z "$VERSION" ]; then + VERSION="$1" + else + print_error "Too many arguments. Only one VERSION argument is allowed." + show_usage + exit 1 + fi + shift + ;; + esac +done + +# Check if version is provided +if [ -z "$VERSION" ]; then + print_error "Version argument is required" + show_usage + exit 1 +fi + +# Validate version format +validate_version "$VERSION" + +# Print release information +print_info "Starting release for version: $VERSION" +if [ "$DRY_RUN" = true ]; then + print_warn "DRY RUN MODE - No changes will be made" +fi + +# Step 1: Create git tag (source of truth for version) +create_git_tag "$VERSION" + +# Step 2: Run build (uses git describe for version) +run_build "$VERSION" + +# Step 3: Create release directories +create_release_dirs "$VERSION" + +# Step 4: Copy binaries +copy_binaries "$VERSION" + +# Step 5: Generate checksums +generate_checksums "$VERSION" + +# Success message +printf "${GREEN}✓${NC} Release created successfully\n" +printf "Version: %s\n" "$VERSION" +printf "Release artifacts: %s/%s/\n" "$RELEASE_DIR" "$VERSION" \ No newline at end of file