diff --git a/.chglog/CHANGELOG.tpl.md b/.chglog/CHANGELOG.tpl.md index 5dce14f..9645095 100644 --- a/.chglog/CHANGELOG.tpl.md +++ b/.chglog/CHANGELOG.tpl.md @@ -5,7 +5,7 @@ {{ range .Unreleased.CommitGroups -}} ### {{ .Title }} {{ range .Commits -}} -- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} ([{{ .Hash.Short }}](https://github.com/PlatformStackPulse/bash-template/commit/{{ .Hash.Long }})) +- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} ([{{ .Hash.Short }}](https://github.com/PlatformStackPulse/git-repo-reconciler/commit/{{ .Hash.Long }})) {{ end }} {{ end }} {{ else -}} @@ -18,7 +18,7 @@ {{ range .CommitGroups -}} ### {{ .Title }} {{ range .Commits -}} -- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} ([{{ .Hash.Short }}](https://github.com/PlatformStackPulse/bash-template/commit/{{ .Hash.Long }})) +- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} ([{{ .Hash.Short }}](https://github.com/PlatformStackPulse/git-repo-reconciler/commit/{{ .Hash.Long }})) {{ end }} {{ end }} diff --git a/.chglog/config.yml b/.chglog/config.yml index 1012629..c403a8e 100644 --- a/.chglog/config.yml +++ b/.chglog/config.yml @@ -2,7 +2,7 @@ style: github template: CHANGELOG.tpl.md info: title: CHANGELOG - repository_url: https://github.com/PlatformStackPulse/bash-template + repository_url: https://github.com/PlatformStackPulse/git-repo-reconciler options: commits: filters: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 95909ef..55d955f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "bash-template", + "name": "git-repo-reconciler", "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "features": { "ghcr.io/devcontainers/features/github-cli:1": {} diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index 494150a..36dc071 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -1,5 +1,5 @@ name: build -description: GitHub composite action to build bash-template +description: GitHub composite action to build git-repo-reconciler (grr) runs: using: composite diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2436c03..d5049bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,12 +116,12 @@ jobs: - name: Verify build artifact run: | - test -f bin/bash-template - bin/bash-template --version + test -f bin/grr + bin/grr --version - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: bash-template-binary - path: bin/bash-template + name: grr-binary + path: bin/grr retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b58e19..2268b08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,12 +27,12 @@ jobs: - name: Build run: | chmod +x scripts/build.sh - BINARY_NAME=bash-template scripts/build.sh "${{ steps.version.outputs.version }}" + scripts/build.sh "${{ steps.version.outputs.version }}" - name: Package release artifacts run: | mkdir -p dist - cp bin/bash-template "dist/bash-template-${{ steps.version.outputs.version }}-linux-amd64" + cp bin/grr "dist/grr-${{ steps.version.outputs.version }}-linux-amd64" cd dist && sha256sum * > SHA256SUMS - name: Upload artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78ca929..81d35f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to Bash Template +# Contributing to Git Repo Reconciler (GRR) Thank you for your interest in contributing! This project follows a set of guidelines to ensure code quality and consistency. @@ -7,8 +7,8 @@ Thank you for your interest in contributing! This project follows a set of guide 1. **Fork the repository** on GitHub 2. **Clone your fork:** ```bash - git clone https://github.com/PlatformStackPulse/bash-template.git - cd bash-template + git clone https://github.com/PlatformStackPulse/git-repo-reconciler.git + cd git-repo-reconciler ``` 3. **Setup development environment:** @@ -80,9 +80,9 @@ All commits must follow the [Conventional Commits](https://www.conventionalcommi **Examples:** ``` feat: add support for parallel processing -feat(cli): add deploy command -fix: resolve timeout handling -fix(logger): fix log rotation +feat(pull): add shallow clone handling +fix: resolve timeout handling on macOS +fix(discovery): fix nameref compatibility with Bash 3.2 docs: update README with examples chore: upgrade ShellCheck to latest ``` @@ -146,12 +146,15 @@ lib/ # Shared libraries (sourced, not executed) ├── config.sh # Configuration management ├── errors.sh # Error codes and handling ├── utils.sh # Common utilities -└── version.sh # Version info +├── version.sh # Version info +├── git.sh # Atomic git operations +└── discovery.sh # Repo discovery & skip patterns src/ # Executable scripts ├── main.sh # Entry point & dispatcher └── commands/ # Subcommand scripts - └── hello.sh # Example command + ├── pull.sh # Bulk-pull reconciliation + └── status.sh # Repository status overview test/ # BATS tests ├── unit/ # Unit tests (mirrors lib/ and src/) @@ -174,9 +177,9 @@ test/ # BATS tests **Integration Tests:** ```bash @test "full CLI flow works" { - run bash src/main.sh hello --name "Test" + run bash src/main.sh pull --help [ "$status" -eq 0 ] - [[ "$output" == *"Hello, Test!"* ]] + [[ "$output" == *"--fetch"* ]] } ``` diff --git a/Dockerfile b/Dockerfile index 423f4f9..3d4594f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ FROM ubuntu:24.04 RUN apt-get update && apt-get install -y --no-install-recommends \ bash \ coreutils \ + git \ curl \ jq \ && rm -rf /var/lib/apt/lists/* @@ -35,8 +36,8 @@ WORKDIR /app RUN groupadd -g 1000 app && \ useradd -u 1000 -g app -s /bin/bash app -COPY --from=base /app/bin/bash-template /usr/local/bin/bash-template +COPY --from=base /app/bin/grr /usr/local/bin/grr USER app -ENTRYPOINT ["bash-template"] +ENTRYPOINT ["grr"] diff --git a/Makefile b/Makefile index 9acb001..b84f052 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: help build run test clean lint fmt security coverage install dev-setup changelog changelog-check # Variables -BINARY_NAME=bash-template +BINARY_NAME=grr VERSION?=dev COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S') @@ -20,7 +20,7 @@ build: ## Build the application (bundle into bin/) @echo "Build complete: bin/$(BINARY_NAME)" run: build ## Build and run the application - @bin/$(BINARY_NAME) hello + @bin/$(BINARY_NAME) --help test: ## Run all tests (BATS) @echo "Running tests..." diff --git a/README.md b/README.md index f1b825c..1bcc49f 100644 --- a/README.md +++ b/README.md @@ -1,224 +1,190 @@ -# Bash Template +# GRR — Git Repo Reconciler ![Bash Version](https://img.shields.io/badge/Bash-4.0+-blue?style=flat-square&logo=gnubash) ![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square) -![CI Status](https://github.com/PlatformStackPulse/bash-template/actions/workflows/ci.yml/badge.svg) +![CI Status](https://github.com/PlatformStackPulse/git-repo-reconciler/actions/workflows/ci.yml/badge.svg) ![ShellCheck](https://img.shields.io/badge/ShellCheck-Passing-green?style=flat-square) -![DevContainer](https://img.shields.io/static/v1?label=DevContainer&message=Ready&color=blue&style=flat-square&logo=visual-studio-code)

- Slim, Production-Ready Bash Template
- Enterprise CI/CD, DevSecOps, clean structure. Optimized for CLI tools and automation scripts. + Bulk-update, inspect, and reconcile git repositories at scale.
+ Fast parallel pulls, status checks, and safe git operations across directory trees.

--- ## Overview -A **minimal, reusable GitHub template** for building production-ready Bash scripts and CLI tools. Supports both **single-script projects** and **multi-script toolkits** with zero bloat. - -**What you get:** -- Clean project structure (lib/src/test separation) -- CLI foundation (argument parsing, help, version) -- Structured logging (colored, leveled, file logging) -- Configuration management (env vars, config files) -- Comprehensive testing (unit tests with BATS) -- DevSecOps (ShellCheck, static analysis) -- GitHub Actions CI/CD (linting, testing, releases) -- Docker support for portable execution -- DevContainer with pre-configured tools -- Conventional Commits & changelog automation - -**What you don't get (keep it slim!):** -- No bloated framework dependencies -- No unused utility functions (add only if needed) -- No over-engineered abstractions -- No Python/Ruby/Node.js wrappers +**GRR** is a CLI tool that finds all git repositories under a directory and reconciles them — fetching, pulling, stashing, resetting, and cleaning — in a single command. Built for developers and platform engineers managing many repos locally. + +**Features:** +- Discover and pull all git repos in a directory tree +- Parallel execution for speed (`-p N`) +- Safe pipeline: fetch → status-check → stash → checkout → reset → clean → pull +- Dry-run mode to preview changes +- Shallow clone handling, submodule updates, garbage collection +- Status overview across all repos (branch, dirty state, ahead/behind) +- Structured logging (colored, leveled, file output) +- Skip patterns to exclude repos +- Configurable branch priority and timeouts +- Strict mode (fail-fast) or continue-on-error (default) --- ## Quick Start -### Using as GitHub Template - ```bash -# Create a new repo from this template -gh repo create my-tool --template PlatformStackPulse/bash-template +# Clone +git clone https://github.com/PlatformStackPulse/git-repo-reconciler.git +cd git-repo-reconciler -# Setup -cd my-tool +# Setup dev tools make dev-setup -# Build & run +# Build make build -./bin/my-tool --help -./bin/my-tool hello --name "World" + +# Run +./bin/grr pull --fetch --check-status ~/projects +./bin/grr status ~/projects ``` -### Example: Add Your First Command +## Installation -The template includes an example command in `src/commands/hello.sh`. Replace it with your own: +Build and install `grr` so it's available system-wide from any terminal: -**1. Create your command:** ```bash -# src/commands/mycommand.sh -#!/usr/bin/env bash -# Command: mycommand — What my command does - -mycommand_usage() { - cat << EOF -Usage: $(basename "$0") mycommand [OPTIONS] - -What my command does in detail. - -OPTIONS: - -n, --name NAME Name to use - -h, --help Show this help -EOF -} - -mycommand_run() { - local name="World" - while [[ $# -gt 0 ]]; do - case "$1" in - -n|--name) name="$2"; shift 2 ;; - -h|--help) mycommand_usage; return 0 ;; - *) log_error "Unknown option: $1"; return 1 ;; - esac - done - log_info "Running mycommand for $name" -} -``` +# Build the binary +make build -**2. Register in main script:** -```bash -# In src/main.sh, add to the command dispatch: -mycommand) shift; source "$SRC_DIR/commands/mycommand.sh"; mycommand_run "$@" ;; +# Copy to ~/.local/bin (create it if it doesn't exist) +mkdir -p ~/.local/bin +cp bin/grr ~/.local/bin/grr +chmod +x ~/.local/bin/grr ``` -**3. Remove the example command:** +If `~/.local/bin` is not already in your `PATH`, add it to your shell profile: + ```bash -rm src/commands/hello.sh -# Update src/main.sh — remove the hello command case -``` +# For zsh (~/.zshrc) +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc ---- +# For bash (~/.bashrc) +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` -## Lean Project Structure +Verify the installation: -``` -bash-template/ -├── src/ # Source scripts -│ ├── main.sh # Entry point & command dispatcher (~50 lines) -│ └── commands/ # Subcommand scripts -│ └── hello.sh # Example command (remove/rename) -├── lib/ # Shared libraries -│ ├── logging.sh # Structured logging (colored, leveled) -│ ├── config.sh # Configuration loading (env + file) -│ ├── errors.sh # Error codes & handling -│ ├── utils.sh # Common utilities (validation, etc.) -│ └── version.sh # Version info (injected at build) -├── test/ -│ ├── unit/ # Unit tests (BATS) -│ │ ├── logging_test.bats -│ │ ├── config_test.bats -│ │ ├── errors_test.bats -│ │ └── commands_test.bats -│ ├── integration/ # Integration tests -│ │ └── flow_test.bats -│ └── test_helper.bash # Shared test utilities -├── Makefile # Core build targets -├── Dockerfile # Container build -├── docker-compose.yml # Local dev environment -├── .github/workflows/ # GitHub Actions (6 workflows) -├── scripts/ # Setup & utility scripts -└── README.md +```bash +grr --version ``` --- -## Two Modes: Single Script vs Toolkit - -### Mode 1: Single Script +## Commands -For simple single-purpose tools, your main.sh stays slim: +### `grr pull` — Reconcile repositories ```bash -#!/usr/bin/env bash -set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/../lib/logging.sh" -source "$SCRIPT_DIR/../lib/config.sh" - -config_load -log_info "Starting my-tool" -# Your logic here -``` +# Recommended: safe pull with full features +grr pull --fetch --check-status --stash -V /repos -### Mode 2: Multi-Command Toolkit +# Fast parallel pull with logging +grr pull --fetch -p 4 --log pull.log /repos -For tools with subcommands (like git, docker): +# Preview before running +grr pull -d --fetch /repos -```bash -case "${1:-}" in - hello) shift; source "$SRC_DIR/commands/hello.sh"; hello_run "$@" ;; - deploy) shift; source "$SRC_DIR/commands/deploy.sh"; deploy_run "$@" ;; - *) main_usage; exit 1 ;; -esac -``` +# With custom branches and skip patterns +grr pull --fetch -b "main,develop" -s "*test*" /repos ---- - -## Features +# Full pipeline +grr pull --fetch --stash --submodules --verify --gc /repos +``` -### Structured Logging +**Pipeline steps (in order):** +1. `--fetch` — Fetch all remotes with pruning and tags +2. `--check-status` — Warn about uncommitted changes +3. `--handle-shallow` — Convert shallow clones to full repos +4. `--stash` — Stash uncommitted changes before pulling +5. Checkout branch (tries `master`, `main`, `develop` or custom `-b`) +6. Hard reset to `origin/` +7. Clean untracked files +8. `--submodules` — Update submodules recursively +9. Pull with rebase +10. `--verify` — Show recent commits +11. `--gc` — Run garbage collection + +### `grr status` — Inspect repositories ```bash -source lib/logging.sh +# Show status of all repos +grr status /repos -log_info "Starting process" # [INFO] Starting process -log_success "Task completed" # [✓] Task completed -log_warning "Check this" # [!] Check this -log_error "Something broke" # [✗] Something broke -log_debug "Verbose detail" # [DEBUG] Verbose detail (only with -V) +# Skip certain repos +grr status -s "*vendor*" /repos ``` -Supports file logging with `--log FILE` and colored/plain output. +Outputs a table with repository name, branch, state (clean/dirty), and remote URL. -### Configuration Management +--- -```bash -source lib/config.sh +## Options + +### Global Options + +| Flag | Description | +|------|-------------| +| `-h, --help` | Show help | +| `-v, --version` | Show version | +| `-V, --verbose` | Enable debug output | +| `--log FILE` | Log to file | + +### Pull Options + +| Flag | Description | +|------|-------------| +| `-d, --dry-run` | Preview without changes | +| `-p, --parallel N` | Run N parallel jobs (default: 1) | +| `-b, --branches B1,B2` | Branches to try (default: master,main,develop) | +| `-s, --skip PATTERN` | Skip repos matching pattern (repeatable) | +| `-t, --timeout SECS` | Git operation timeout (default: 300) | +| `--fetch` | Fetch remotes first | +| `--stash` | Stash changes before pulling | +| `--check-status` | Warn about uncommitted changes | +| `--submodules` | Update submodules recursively | +| `--verify` | Show recent commits after pull | +| `--handle-shallow` | Convert shallow clones | +| `--gc` | Garbage collect after pull | +| `--strict` | Fail on first error | +| `--max-log N` | Commits to show with --verify (default: 3) | -# Load from environment variables with defaults -config_load -echo "$APP_NAME" # from APP_NAME env or default -echo "$DEBUG" # from DEBUG env or default "false" -``` +--- -### Error Handling +## Project Structure -```bash -source lib/errors.sh - -# Predefined error codes -exit $ERR_INVALID_INPUT # 10 -exit $ERR_NOT_FOUND # 11 -exit $ERR_PERMISSION # 12 -exit $ERR_TIMEOUT # 13 -exit $ERR_CONFIGURATION # 14 -exit $ERR_DEPENDENCY # 15 ``` - -### Testing with BATS - -```bash -# test/unit/logging_test.bats -@test "log_info outputs INFO prefix" { - source lib/logging.sh - run log_info "test message" - [[ "$output" == *"[INFO]"* ]] -} +git-repo-reconciler/ +├── src/ +│ ├── main.sh # Entry point & command dispatcher +│ └── commands/ +│ ├── pull.sh # Bulk-pull reconciliation command +│ └── status.sh # Status overview command +├── lib/ +│ ├── git.sh # Atomic git operations +│ ├── discovery.sh # Repo discovery & skip patterns +│ ├── logging.sh # Structured logging (colored, leveled) +│ ├── config.sh # Configuration (env vars + defaults) +│ ├── errors.sh # Error codes & handling +│ ├── utils.sh # Validation utilities +│ └── version.sh # Version info (injected at build) +├── test/ +│ ├── unit/ # Unit tests (BATS) +│ └── integration/ # Integration tests +├── Makefile # Build targets +├── Dockerfile # Container build +└── .github/workflows/ # CI/CD ``` --- @@ -227,48 +193,14 @@ exit $ERR_DEPENDENCY # 15 ```bash make help # Show all targets -make build # Build the application (bundle into bin/) -make run # Build and run -make test # Run all tests (BATS) -make test-unit # Run unit tests only -make lint # Run ShellCheck linter +make build # Build portable binary into bin/grr +make run # Build and show help +make test # Run all tests +make lint # Run ShellCheck make fmt # Format with shfmt -make security # Run security checks -make coverage # Generate test coverage +make security # Security checks make clean # Clean build artifacts -make install # Check runtime dependencies -make dev-setup # Full development environment setup -make changelog # Regenerate CHANGELOG.md -make version # Show version -make all # Run all targets -``` - ---- - -## CI/CD Pipeline - -6 GitHub Actions workflows (matching go-template conventions): - -1. **CI Pipeline** (`ci.yml`) — Lint, test, security, build on every PR -2. **Changelog** (`changelog.yml`) — Auto-update CHANGELOG.md on main -3. **CodeQL** (`codeql.yml`) — Weekly security analysis -4. **Dependencies** (`dependencies.yml`) — Weekly dependency checks -5. **Release** (`release.yml`) — Multi-platform packaging on tags -6. **Version Bump** (`version-bump.yml`) — Manual version bumping - ---- - -## Security & DevSecOps - -- **ShellCheck** — Static analysis for shell scripts -- **shfmt** — Consistent formatting -- **CodeQL** — SAST (Static Application Security Testing) -- **Dependabot** — Automated dependency alerts - -```bash -make lint # Run ShellCheck -make fmt # Format with shfmt -make security # Run security checks +make dev-setup # Install dev tools + git hooks ``` --- @@ -276,23 +208,15 @@ make security # Run security checks ## Docker ```bash -# Build image -docker build -t my-tool . - -# Run -docker run --rm my-tool hello --name "Docker" - -# Development with docker-compose -docker-compose run dev -docker-compose run test -docker-compose run lint +docker build -t grr . +docker run --rm -v /your/repos:/repos grr pull --fetch /repos ``` --- ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, commit conventions, and testing guidelines. +See [CONTRIBUTING.md](CONTRIBUTING.md) for workflow, commit conventions, and testing guidelines. ## Security @@ -300,4 +224,4 @@ See [SECURITY.md](SECURITY.md) for vulnerability reporting and security scanning ## License -[MIT License](LICENSE) — Copyright (c) 2026 PE Stack Pulse +[MIT License](LICENSE) diff --git a/SECURITY.md b/SECURITY.md index 247ce28..7d3f518 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -53,7 +53,7 @@ Security scans run automatically on: ## Common Shell Script Vulnerabilities -This template is designed to prevent common shell script issues: +This project is designed to prevent common shell script issues: - **Command injection** — All variables are quoted - **Path traversal** — Paths are validated before use diff --git a/TEMPLATE_GUIDE.md b/TEMPLATE_GUIDE.md index 9528db3..278743f 100644 --- a/TEMPLATE_GUIDE.md +++ b/TEMPLATE_GUIDE.md @@ -1,35 +1,40 @@ -# Bash Template - Streamlined & Ready +# GRR — Architecture & Design Guide -A **slim, production-ready** Bash project template for CLI tools and automation scripts. +An overview of the design patterns, project structure, and conventions used in Git Repo Reconciler. -## Actual Project Stats +## Project Stats -- **Shell Files**: 10 (5 lib + 2 src + 3 test files) -- **Main Codebase**: ~300 LOC (without tests) +- **Shell Files**: 10 (7 lib + 2 src commands + 1 entry point) +- **Test Files**: 9 (7 unit + 1 integration + 1 helper) - **Test Framework**: BATS (Bash Automated Testing System) - **Workflows**: 6 GitHub Actions workflows - **Dependencies**: ShellCheck, shfmt, BATS (dev only) -## Core File Structure Breakdown +## Project Structure ``` -bash-template/ +git-repo-reconciler/ ├── src/ -│ ├── main.sh # Entry point & dispatcher (~50 lines) +│ ├── main.sh # Entry point & command dispatcher │ └── commands/ -│ └── hello.sh # Example command (remove/rename) +│ ├── pull.sh # Bulk-pull reconciliation command +│ └── status.sh # Repository status overview command ├── lib/ │ ├── logging.sh # Structured logging (colored, leveled) │ ├── config.sh # Config loading (env vars + defaults) │ ├── errors.sh # Error codes & handling │ ├── utils.sh # Common utilities (validation, etc.) -│ └── version.sh # Version info (injected at build) +│ ├── version.sh # Version info (injected at build) +│ ├── git.sh # Atomic git operations (fetch, pull, reset, etc.) +│ └── discovery.sh # Repo discovery & skip patterns ├── test/ │ ├── unit/ -│ │ ├── logging_test.bats +│ │ ├── commands_test.bats │ │ ├── config_test.bats +│ │ ├── discovery_test.bats │ │ ├── errors_test.bats -│ │ ├── commands_test.bats +│ │ ├── git_test.bats +│ │ ├── logging_test.bats │ │ └── utils_test.bats │ ├── integration/ │ │ └── flow_test.bats @@ -42,7 +47,7 @@ bash-template/ │ └── apply-branch-protection.sh # GitHub branch protection ├── .github/ │ ├── workflows/ # 6 CI/CD workflows -│ ├── actions/ # 2 composite actions +│ ├── actions/ # Composite actions │ ├── ISSUE_TEMPLATE/ # Bug report & feature request │ ├── CODEOWNERS │ └── pull_request_template.md @@ -68,10 +73,11 @@ bash-template/ - CLI framework (argument parsing, help, version, subcommands) - Structured logging (colored, leveled, file logging) - Configuration management (environment variables + defaults) -- Comprehensive testing with BATS -- Enterprise CI/CD (GitHub Actions) +- Portable timeout wrapper (works on both Linux and macOS) +- Bash 3.2+ compatibility (no namerefs or Bash 4-only features) +- Comprehensive testing with BATS (66 tests) +- Enterprise CI/CD (GitHub Actions — Ubuntu + macOS) - Docker support -- DevContainer support - Static analysis (ShellCheck, shfmt) - Git hooks & branch protection - Conventional Commits & changelog @@ -81,22 +87,7 @@ bash-template/ - No bloated framework abstractions - No unused utility functions -## Customization Checklist - -When creating a new project from this template: - -1. [ ] Update `README.md` with your project description -2. [ ] Rename `APP_NAME` in `lib/config.sh` default -3. [ ] Rename `BINARY_NAME` in `Makefile` -4. [ ] Replace `src/commands/hello.sh` with your commands -5. [ ] Update `src/main.sh` command dispatch -6. [ ] Update `.github/CODEOWNERS` -7. [ ] Update `CHANGELOG.md` and `.chglog/` repository URL -8. [ ] Add your libraries to `lib/` -9. [ ] Add tests to `test/unit/` and `test/integration/` -10. [ ] Run `make all` to verify everything works - -## Key Features +## Key Design Patterns ### 1. Library Pattern (Source, Don't Execute) @@ -104,6 +95,8 @@ Libraries in `lib/` are **sourced**, not executed: ```bash source "$LIB_DIR/logging.sh" source "$LIB_DIR/config.sh" +source "$LIB_DIR/git.sh" +source "$LIB_DIR/discovery.sh" ``` Each library has a **double-source guard**: @@ -117,8 +110,8 @@ readonly _LOGGING_SH_LOADED=1 Commands live in `src/commands/` and are dispatched from `main.sh`: ```bash case "$command" in - hello) shift; source "$SRC_DIR/commands/hello.sh"; hello_run "$@" ;; - deploy) shift; source "$SRC_DIR/commands/deploy.sh"; deploy_run "$@" ;; + pull) shift; source "$SRC_DIR/commands/pull.sh"; pull_run "$@" ;; + status) shift; source "$SRC_DIR/commands/status.sh"; status_run "$@" ;; esac ``` @@ -130,19 +123,21 @@ Following `set -euo pipefail` best practices: - `pushd/popd` (not `cd/cd -`) - `find -print0` with `read -r -d ''` - All variables quoted: `"$var"` +- Portable timeout wrapper (`_git_timeout`) for macOS compatibility +- `eval`-based array passing instead of `local -n` namerefs (Bash 3.2 compat) ### 4. Build System `scripts/build.sh` bundles all libraries and commands into a single portable script: ```bash make build -# Creates: bin/bash-template (self-contained, no dependencies) +# Creates: bin/grr (self-contained, no dependencies) ``` ### 5. Testing with BATS ```bash -make test # All tests -make test-unit # Unit tests only +make test # All tests (66 tests) +make test-unit # Unit tests only make test-integration # Integration tests ``` diff --git a/WORKFLOW.md b/WORKFLOW.md index 1613526..0e15bd3 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -86,7 +86,7 @@ scripts/apply-branch-protection.sh Optional overrides: ```bash -GITHUB_OWNER=PlatformStackPulse GITHUB_REPO=bash-template BRANCH=main scripts/apply-branch-protection.sh +GITHUB_OWNER=PlatformStackPulse GITHUB_REPO=git-repo-reconciler BRANCH=main scripts/apply-branch-protection.sh ``` ## Quick Apply Checklist (GitHub UI) diff --git a/docker-compose.yml b/docker-compose.yml index 68b6082..22a5905 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - container_name: bash-template-dev + container_name: grr-dev volumes: - .:/app working_dir: /app @@ -15,7 +15,7 @@ services: build: context: . dockerfile: Dockerfile - container_name: bash-template-test + container_name: grr-test volumes: - .:/app working_dir: /app @@ -23,7 +23,7 @@ services: lint: image: koalaman/shellcheck-alpine:stable - container_name: bash-template-lint + container_name: grr-lint volumes: - .:/app working_dir: /app diff --git a/lib/config.sh b/lib/config.sh index 738bed6..b1c30b4 100755 --- a/lib/config.sh +++ b/lib/config.sh @@ -8,7 +8,7 @@ # echo "$DEBUG" # # Environment variables (all optional, with defaults): -# APP_NAME — Application name (default: "bash-template") +# APP_NAME — Application name (default: "grr") # DEBUG — Enable debug mode (default: "false") # VERSION — Application version (default: "dev") # LOG_FILE — Log file path (default: empty/disabled) @@ -20,7 +20,7 @@ readonly _CONFIG_SH_LOADED=1 config_load() { # Application settings - APP_NAME="${APP_NAME:-bash-template}" + APP_NAME="${APP_NAME:-grr}" DEBUG="${DEBUG:-false}" VERSION="${VERSION:-dev}" LOG_FILE="${LOG_FILE:-}" diff --git a/lib/discovery.sh b/lib/discovery.sh new file mode 100644 index 0000000..b2d6e15 --- /dev/null +++ b/lib/discovery.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# lib/discovery.sh — Repository discovery and filtering +# +# Usage: +# source lib/discovery.sh +# discover_repos "/path/to/dir" repos_array +# should_skip_repo "/path/to/repo" patterns_array + +# Prevent double-sourcing +[[ -n "${_DISCOVERY_SH_LOADED:-}" ]] && return 0 +readonly _DISCOVERY_SH_LOADED=1 + +# Find all git repositories under a directory. +# Populates the named array variable with repo paths. +# Arguments: +# $1 — target directory +# $2 — name of array variable to populate +discover_repos() { + local target_dir="$1" + local _var_name="$2" + local _tmp=() + + while IFS= read -r -d '' git_dir; do + _tmp+=("$(dirname "$git_dir")") + done < <(find "$target_dir" -name ".git" -type d -print0 2>/dev/null) + + eval "$_var_name=()" + local _i + for _i in "${_tmp[@]}"; do + eval "$_var_name+=(\"\$_i\")" + done +} + +# Check if a repo path matches any skip pattern. +# Returns 0 (should skip) or 1 (should not skip). +# Arguments: +# $1 — repo path +# remaining args — patterns to match against +# shellcheck disable=SC2053 +should_skip_repo() { + local repo_path="$1" + shift + + local pattern + for pattern in "$@"; do + if [[ "$repo_path" == $pattern ]]; then + return 0 + fi + done + return 1 +} + +# Validate that a target directory is usable for repo discovery. +validate_target_dir() { + local dir="$1" + + if [[ ! -d "$dir" ]]; then + log_error "Directory does not exist: $dir" + return 1 + fi + if [[ ! -r "$dir" ]]; then + log_error "No read permission for directory: $dir" + return 1 + fi +} diff --git a/lib/git.sh b/lib/git.sh new file mode 100644 index 0000000..76a84ee --- /dev/null +++ b/lib/git.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# lib/git.sh — Atomic git operations for repository reconciliation +# +# Usage: +# source lib/git.sh +# git_fetch +# git_pull_rebase +# git_stash_changes + +# Prevent double-sourcing +[[ -n "${_GIT_SH_LOADED:-}" ]] && return 0 +readonly _GIT_SH_LOADED=1 + +# Portable timeout wrapper (macOS lacks GNU timeout) +_git_timeout() { + local secs="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$secs" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$secs" "$@" + else + "$@" + fi +} + +# Check if a remote origin is configured +git_has_remote() { + git remote get-url origin >/dev/null 2>&1 +} + +# Fetch all remotes with pruning +git_fetch() { + if ! git_has_remote; then + log_debug "No remote configured, skipping fetch" + return 0 + fi + log_debug "Running: git fetch --all --prune --tags" + if ! _git_timeout "${TIMEOUT:-300}" git fetch --all --prune --tags >/dev/null 2>&1; then + log_error "git fetch failed" + return 1 + fi +} + +# Pull with rebase +git_pull_rebase() { + if ! git_has_remote; then + log_debug "No remote configured, skipping pull" + return 0 + fi + log_debug "Running: git pull --rebase" + if ! _git_timeout "${TIMEOUT:-300}" git pull --rebase >/dev/null 2>&1; then + log_error "git pull --rebase failed" + return 1 + fi +} + +# Stash uncommitted changes (including untracked) +git_stash_changes() { + log_debug "Stashing changes with: git stash -u" + if ! git stash -u >/dev/null 2>&1; then + log_warning "Failed to stash changes (continuing)" + fi +} + +# Hard reset to origin/ with HEAD fallback +git_reset_hard() { + local current_branch + current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD") + + if git_has_remote; then + log_debug "Running: git reset --hard origin/$current_branch" + if ! _git_timeout "${TIMEOUT:-300}" git reset --hard "origin/$current_branch" >/dev/null 2>&1; then + log_debug "Falling back to: git reset --hard HEAD" + if ! _git_timeout "${TIMEOUT:-300}" git reset --hard HEAD >/dev/null 2>&1; then + log_error "git reset failed" + return 1 + fi + fi + else + log_debug "No remote, resetting to HEAD" + if ! _git_timeout "${TIMEOUT:-300}" git reset --hard HEAD >/dev/null 2>&1; then + log_error "git reset failed" + return 1 + fi + fi +} + +# Clean untracked files and directories +git_clean_untracked() { + log_debug "Running: git clean -fd" + if ! _git_timeout "${TIMEOUT:-300}" git clean -fd >/dev/null 2>&1; then + log_error "git clean -fd failed" + return 1 + fi +} + +# Checkout first available branch from a list +# Arguments: branch names (space-separated) +git_checkout_branch() { + local branch + for branch in "$@"; do + log_debug "Trying to checkout branch: $branch" + if git checkout "$branch" &>/dev/null; then + log_debug "Checked out: $branch" + return 0 + fi + done + log_error "Could not checkout any of: $*" + return 1 +} + +# Check for uncommitted changes; returns 1 if dirty +git_check_dirty() { + local status + status=$(git status --porcelain 2>/dev/null || echo "") + if [[ -n "$status" ]]; then + echo "$status" + return 1 + fi + return 0 +} + +# Check if repository is a shallow clone +git_is_shallow() { + local shallow + shallow=$(git rev-parse --is-shallow-repository 2>/dev/null || echo "false") + [[ "$shallow" == "true" ]] +} + +# Convert shallow clone to full repository +git_unshallow() { + log_debug "Converting shallow clone to full repository" + if ! _git_timeout "${TIMEOUT:-300}" git fetch --unshallow >/dev/null 2>&1; then + log_warning "Could not unshallow repository (continuing)" + fi +} + +# Update submodules recursively (silently skips if none exist) +git_update_submodules() { + if ! git config --file .gitmodules --name-only --get-regexp path >/dev/null 2>&1; then + log_debug "No submodules found" + return 0 + fi + log_debug "Running: git submodule update --init --recursive" + if ! _git_timeout "${TIMEOUT:-300}" git submodule update --init --recursive >/dev/null 2>&1; then + log_warning "Submodule update failed (continuing)" + fi +} + +# Run git garbage collection +git_garbage_collect() { + log_debug "Running: git gc --auto" + if ! _git_timeout "${TIMEOUT:-300}" git gc --auto >/dev/null 2>&1; then + log_warning "Garbage collection failed (continuing)" + fi +} + +# Show recent commits (for verification) +git_show_recent_commits() { + local count="${1:-3}" + local log_output + log_output=$(git log -"$count" --oneline 2>/dev/null || echo "") + if [[ -n "$log_output" ]]; then + log_magenta " Recent commits:" + echo " ${log_output//$'\n'/$'\n' }" + fi +} + +# Get the remote origin URL +git_remote_url() { + git config --get remote.origin.url 2>/dev/null || echo "unknown" +} + +# Get current branch name +git_current_branch() { + git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached" +} + +# Get ahead/behind counts relative to upstream +git_ahead_behind() { + local upstream + upstream=$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) || return 0 + local counts + counts=$(git rev-list --left-right --count "$upstream"...HEAD 2>/dev/null) || return 0 + echo "$counts" +} diff --git a/scripts/apply-branch-protection.sh b/scripts/apply-branch-protection.sh index 7620625..cd7e8d5 100755 --- a/scripts/apply-branch-protection.sh +++ b/scripts/apply-branch-protection.sh @@ -6,7 +6,7 @@ # - GITHUB_TOKEN env var with repo admin permissions # Optional: # - GITHUB_OWNER (default: PlatformStackPulse) -# - GITHUB_REPO (default: bash-template) +# - GITHUB_REPO (default: git-repo-reconciler) # - BRANCH (default: main) set -euo pipefail @@ -28,7 +28,7 @@ if [[ -z "${GITHUB_TOKEN:-}" ]]; then fi GITHUB_OWNER="${GITHUB_OWNER:-PlatformStackPulse}" -GITHUB_REPO="${GITHUB_REPO:-bash-template}" +GITHUB_REPO="${GITHUB_REPO:-git-repo-reconciler}" BRANCH="${BRANCH:-main}" API_URL="https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/branches/${BRANCH}/protection" diff --git a/scripts/build.sh b/scripts/build.sh index 49eb42a..916586b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -13,7 +13,7 @@ BASH_VER="$(bash --version | head -1 | awk '{print $4}')" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" BIN_DIR="$PROJECT_ROOT/bin" -BINARY_NAME="${BINARY_NAME:-bash-template}" +BINARY_NAME="${BINARY_NAME:-grr}" mkdir -p "$BIN_DIR" diff --git a/src/commands/hello.sh b/src/commands/hello.sh deleted file mode 100755 index 69ffc11..0000000 --- a/src/commands/hello.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# src/commands/hello.sh — Example command (remove/rename for your project) -# -# Usage: -# ./src/main.sh hello [OPTIONS] -# ./src/main.sh hello --name "Alice" - -hello_usage() { - cat </dev/null 2>&1; then + log_error "Cannot enter directory: $repo_dir" + return 1 + fi + + local remote_url + remote_url=$(git_remote_url) + + # ── Dry-run ────────────────────────────────────────────────────────── + if [[ "${_PULL_DRY_RUN}" == "true" ]]; then + log_info "[DRY-RUN] Would process: $repo_name" + echo " Remote: $remote_url" + echo " Operations:" + if [[ "${_PULL_FETCH}" == "true" ]]; then echo " - git fetch --all --prune --tags"; fi + if [[ "${_PULL_CHECK_STATUS}" == "true" ]]; then echo " - git status check"; fi + if [[ "${_PULL_HANDLE_SHALLOW}" == "true" ]]; then echo " - check/unshallow if needed"; fi + if [[ "${_PULL_STASH}" == "true" ]]; then echo " - git stash -u"; fi + echo " - git checkout ${_PULL_BRANCHES[0]}" + echo " - git reset --hard origin/${_PULL_BRANCHES[0]}" + echo " - git clean -fd" + if [[ "${_PULL_SUBMODULES}" == "true" ]]; then echo " - git submodule update --recursive"; fi + echo " - git pull --rebase" + if [[ "${_PULL_VERIFY}" == "true" ]]; then echo " - show last ${_PULL_MAX_LOG} commits"; fi + if [[ "${_PULL_GC}" == "true" ]]; then echo " - git gc --auto"; fi + popd >/dev/null 2>&1 || true + return 0 + fi + + # ── Live execution pipeline ────────────────────────────────────────── + local rc=0 + + # 1. Fetch + if [[ $rc -eq 0 && "${_PULL_FETCH}" == "true" ]]; then + if ! git_fetch; then rc=1; fi + fi + + # 2. Status check + if [[ $rc -eq 0 && "${_PULL_CHECK_STATUS}" == "true" ]]; then + local dirty + if dirty=$(git_check_dirty); then + : # clean + else + log_warning "Uncommitted changes in $repo_name:" + echo " ${dirty//$'\n'/$'\n' }" + if [[ "${_PULL_STASH}" != "true" ]]; then + log_error "Cannot pull with uncommitted changes (use --stash)" + rc=1 + fi + fi + fi + + # 3. Handle shallow repos + if [[ $rc -eq 0 && "${_PULL_HANDLE_SHALLOW}" == "true" ]]; then + if git_is_shallow; then + log_warning "Shallow clone detected, unshallowing..." + git_unshallow + fi + fi + + # 4. Stash + if [[ $rc -eq 0 && "${_PULL_STASH}" == "true" ]]; then + git_stash_changes + fi + + # 5. Checkout branch + if [[ $rc -eq 0 ]]; then + if ! git_checkout_branch "${_PULL_BRANCHES[@]}"; then rc=1; fi + fi + + # 6. Reset + if [[ $rc -eq 0 ]]; then + if ! git_reset_hard; then rc=1; fi + fi + + # 7. Clean + if [[ $rc -eq 0 ]]; then + if ! git_clean_untracked; then rc=1; fi + fi + + # 8. Submodules + if [[ $rc -eq 0 && "${_PULL_SUBMODULES}" == "true" ]]; then + git_update_submodules + fi + + # 9. Pull + if [[ $rc -eq 0 ]]; then + if ! git_pull_rebase; then rc=1; fi + fi + + # 10. Verify + if [[ $rc -eq 0 && "${_PULL_VERIFY}" == "true" ]]; then + git_show_recent_commits "${_PULL_MAX_LOG}" + fi + + # 11. GC + if [[ $rc -eq 0 && "${_PULL_GC}" == "true" ]]; then + git_garbage_collect + fi + + popd >/dev/null 2>&1 || true + + if [[ $rc -eq 0 ]]; then + log_success "$repo_name updated" + else + log_error "$repo_name failed to update" + fi + return $rc +} + +# ── Print summary ──────────────────────────────────────────────────────────── + +_pull_print_summary() { + local total="$1" success="$2" failed="$3" skipped="$4" + + echo "" + print_separator "=" + echo " Total repositories found: $total" + echo " Successfully updated: $success" + echo " Failed updates: $failed" + echo " Skipped: $skipped" + print_separator "=" + + if [[ $failed -eq 0 && $total -gt 0 ]]; then + log_success "All repositories reconciled successfully!" + return 0 + elif [[ $total -eq 0 ]]; then + log_warning "No git repositories found" + return 1 + else + log_error "Some repositories failed (see errors above)" + return 1 + fi +} + +# ── Entry point ────────────────────────────────────────────────────────────── + +pull_run() { + # Command-local defaults + local target_dir="${PWD}" + _PULL_DRY_RUN="false" + _PULL_FETCH="false" + _PULL_STASH="false" + _PULL_CHECK_STATUS="false" + _PULL_SUBMODULES="false" + _PULL_VERIFY="false" + _PULL_HANDLE_SHALLOW="false" + _PULL_GC="false" + _PULL_MAX_LOG=3 + _PULL_BRANCHES=(master main develop) + _PULL_SKIP=() + local parallel=1 + local strict="false" + + # Parse command options + while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + pull_usage + return 0 + ;; + -d | --dry-run) + _PULL_DRY_RUN="true" + shift + ;; + --fetch) + _PULL_FETCH="true" + shift + ;; + --stash) + _PULL_STASH="true" + shift + ;; + --check-status) + _PULL_CHECK_STATUS="true" + shift + ;; + --submodules) + _PULL_SUBMODULES="true" + shift + ;; + --verify) + _PULL_VERIFY="true" + shift + ;; + --handle-shallow) + _PULL_HANDLE_SHALLOW="true" + shift + ;; + --gc) + _PULL_GC="true" + shift + ;; + --strict) + strict="true" + shift + ;; + -p | --parallel) + if [[ $# -lt 2 ]]; then + log_error "--parallel requires a number" + return "$ERR_INVALID_INPUT" + fi + parallel="$2" + shift 2 + ;; + -b | --branches) + if [[ $# -lt 2 ]]; then + log_error "--branches requires a comma-separated list" + return "$ERR_INVALID_INPUT" + fi + IFS=',' read -ra _PULL_BRANCHES <<<"$2" + shift 2 + ;; + -s | --skip) + if [[ $# -lt 2 ]]; then + log_error "--skip requires a pattern" + return "$ERR_INVALID_INPUT" + fi + _PULL_SKIP+=("$2") + shift 2 + ;; + -t | --timeout) + if [[ $# -lt 2 ]]; then + log_error "--timeout requires seconds" + return "$ERR_INVALID_INPUT" + fi + TIMEOUT="$2" + export TIMEOUT + shift 2 + ;; + --max-log) + if [[ $# -lt 2 ]]; then + log_error "--max-log requires a number" + return "$ERR_INVALID_INPUT" + fi + _PULL_MAX_LOG="$2" + shift 2 + ;; + -*) + log_error "Unknown option: $1" + pull_usage + return "$ERR_INVALID_INPUT" + ;; + *) + target_dir="$1" + shift + ;; + esac + done + + # Validate + if ! validate_target_dir "$target_dir"; then + return "$ERR_NOT_FOUND" + fi + if ! [[ "$parallel" =~ ^[1-9][0-9]*$ ]]; then + log_error "Parallel count must be a positive integer (got: '$parallel')" + return "$ERR_INVALID_INPUT" + fi + + # Discover repos + local repos=() + discover_repos "$target_dir" repos + local total=${#repos[@]} + + if [[ $total -eq 0 ]]; then + log_warning "No git repositories found in $target_dir" + _pull_print_summary 0 0 0 0 + return 1 + fi + + log_info "Starting reconciliation from: $target_dir" + if [[ "${_PULL_DRY_RUN}" == "true" ]]; then + log_warning "DRY-RUN MODE: No changes will be made" + fi + log_info "Found $total repository/repositories" + + # Show config in verbose mode + log_debug "Configuration:" + log_debug " Parallel: $parallel" + log_debug " Timeout: ${TIMEOUT}s" + log_debug " Branches: ${_PULL_BRANCHES[*]}" + if [[ "${_PULL_FETCH}" == "true" ]]; then log_debug " Fetch first: yes"; fi + if [[ "${_PULL_CHECK_STATUS}" == "true" ]]; then log_debug " Check status: yes"; fi + if [[ "${_PULL_STASH}" == "true" ]]; then log_debug " Stash changes: yes"; fi + if [[ "${_PULL_SUBMODULES}" == "true" ]]; then log_debug " Update submodules: yes"; fi + if [[ "${_PULL_VERIFY}" == "true" ]]; then log_debug " Verify pull: yes"; fi + if [[ "${_PULL_HANDLE_SHALLOW}" == "true" ]]; then log_debug " Handle shallow: yes"; fi + if [[ "${_PULL_GC}" == "true" ]]; then log_debug " Garbage collect: yes"; fi + + # ── Execute ────────────────────────────────────────────────────────── + local success=0 failed=0 skipped=0 + + if [[ $parallel -gt 1 ]]; then + log_info "Processing with $parallel parallel jobs" + log_warning "Parallel mode — output may interleave" + + local tmpdir + tmpdir=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$tmpdir'" EXIT + + local running=0 job_id=0 + for repo in "${repos[@]}"; do + while [[ $running -ge $parallel ]]; do + wait -n 2>/dev/null || true + running=$(jobs -rp | wc -l) + done + + job_id=$((job_id + 1)) + ( + local result + if _pull_process_repo "$repo"; then + result="success" + else + local rc=$? + if [[ $rc -eq 2 ]]; then + result="skipped" + else + result="failed" + fi + fi + echo "$result" >"$tmpdir/job_${job_id}" + ) & + running=$(jobs -rp | wc -l) + done + wait + + # Tally results from temp files + for f in "$tmpdir"/job_*; do + [[ -f "$f" ]] || continue + case "$(cat "$f")" in + success) success=$((success + 1)) ;; + skipped) skipped=$((skipped + 1)) ;; + *) failed=$((failed + 1)) ;; + esac + done + rm -rf "$tmpdir" + else + for repo in "${repos[@]}"; do + if _pull_process_repo "$repo"; then + success=$((success + 1)) + else + local rc=$? + if [[ $rc -eq 2 ]]; then + skipped=$((skipped + 1)) + else + failed=$((failed + 1)) + if [[ "$strict" == "true" ]]; then + _pull_print_summary "$total" "$success" "$failed" "$skipped" + return 1 + fi + fi + fi + done + fi + + _pull_print_summary "$total" "$success" "$failed" "$skipped" +} diff --git a/src/commands/status.sh b/src/commands/status.sh new file mode 100644 index 0000000..84bfc1b --- /dev/null +++ b/src/commands/status.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# src/commands/status.sh — Show status of all git repos in a directory +# +# Usage: +# grr status [OPTIONS] [DIRECTORY] +# grr status /repos + +status_usage() { + cat </dev/null 2>&1; then + printf " %-30s %-15s %-12s %s\n" "$name" "?" "error" "?" + continue + fi + + local branch state remote ahead_behind + branch=$(git_current_branch) + remote=$(git_remote_url) + + if git_check_dirty >/dev/null 2>&1; then + state="clean" + else + state="dirty" + fi + + # Append ahead/behind if available + ahead_behind=$(git_ahead_behind) + if [[ -n "$ahead_behind" ]]; then + local behind ahead + behind=$(echo "$ahead_behind" | awk '{print $1}') + ahead=$(echo "$ahead_behind" | awk '{print $2}') + if [[ "$behind" -gt 0 || "$ahead" -gt 0 ]]; then + state="${state} ↓${behind}↑${ahead}" + fi + fi + + # Truncate remote for display + if [[ ${#remote} -gt 40 ]]; then + remote="…${remote: -39}" + fi + + printf " %-30s %-15s %-12s %s\n" "$name" "$branch" "$state" "$remote" + popd >/dev/null 2>&1 || true + done +} diff --git a/src/main.sh b/src/main.sh index e07b266..28bd440 100755 --- a/src/main.sh +++ b/src/main.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# src/main.sh — Entry point and command dispatcher +# src/main.sh — GRR (Git Repo Reconciler) entry point and command dispatcher # # Usage: -# ./src/main.sh [COMMAND] [OPTIONS] -# ./src/main.sh hello --name "World" -# ./src/main.sh --version -# ./src/main.sh --help +# grr [COMMAND] [OPTIONS] +# grr pull --fetch --stash /repos +# grr status /repos +# grr --version set -euo pipefail @@ -26,15 +26,21 @@ source "$LIB_DIR/errors.sh" source "$LIB_DIR/utils.sh" # shellcheck source=../lib/version.sh source "$LIB_DIR/version.sh" +# shellcheck source=../lib/git.sh +source "$LIB_DIR/git.sh" +# shellcheck source=../lib/discovery.sh +source "$LIB_DIR/discovery.sh" main_usage() { cat < file.txt + git add . + git commit -q -m "initial commit" + cd "$PROJECT_ROOT" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +@test "full flow: build and run pull --help" { + run bash "$PROJECT_ROOT/scripts/build.sh" "test" "abc1234" "2026-01-01" + [ "$status" -eq 0 ] + [ -f "$PROJECT_ROOT/bin/grr" ] + + run "$PROJECT_ROOT/bin/grr" pull --help + [ "$status" -eq 0 ] + [[ "$output" == *"--fetch"* ]] } -@test "full flow: build and run hello" { - # Build +@test "full flow: build and run status" { run bash "$PROJECT_ROOT/scripts/build.sh" "test" "abc1234" "2026-01-01" [ "$status" -eq 0 ] - [ -f "$PROJECT_ROOT/bin/bash-template" ] - # Run built binary - run "$PROJECT_ROOT/bin/bash-template" hello --name "Integration" + run "$PROJECT_ROOT/bin/grr" status "$TEST_DIR" [ "$status" -eq 0 ] - [[ "$output" == *"Hello, Integration!"* ]] + [[ "$output" == *"repo-alpha"* ]] } @test "full flow: version output after build" { - run "$PROJECT_ROOT/bin/bash-template" --version + run "$PROJECT_ROOT/bin/grr" --version [ "$status" -eq 0 ] [[ "$output" == *"Version:"* ]] } @test "full flow: help output" { - run "$PROJECT_ROOT/bin/bash-template" --help + run "$PROJECT_ROOT/bin/grr" --help [ "$status" -eq 0 ] - [[ "$output" == *"COMMANDS"* ]] + [[ "$output" == *"GRR"* ]] + [[ "$output" == *"pull"* ]] + [[ "$output" == *"status"* ]] } -@test "full flow: verbose mode" { - run bash "$PROJECT_ROOT/src/main.sh" -V hello --name "Debug" +@test "full flow: dry-run pull" { + run bash "$PROJECT_ROOT/src/main.sh" pull -d --fetch "$TEST_DIR" [ "$status" -eq 0 ] - [[ "$output" == *"Hello, Debug!"* ]] + [[ "$output" == *"DRY-RUN"* ]] + [[ "$output" == *"repo-alpha"* ]] +} + +@test "full flow: pull with local repo" { + run bash "$PROJECT_ROOT/src/main.sh" pull "$TEST_DIR" + [ "$status" -eq 0 ] + [[ "$output" == *"repo-alpha"* ]] +} + +@test "full flow: status with local repo" { + run bash "$PROJECT_ROOT/src/main.sh" status "$TEST_DIR" + [ "$status" -eq 0 ] + [[ "$output" == *"repo-alpha"* ]] + [[ "$output" == *"clean"* ]] } @test "full flow: file logging" { local tmplog tmplog=$(mktemp) - run bash "$PROJECT_ROOT/src/main.sh" --log "$tmplog" hello --name "Logged" + run bash "$PROJECT_ROOT/src/main.sh" --log "$tmplog" pull "$TEST_DIR" [ "$status" -eq 0 ] - grep -q "Logged" "$tmplog" + grep -q "repo-alpha" "$tmplog" rm -f "$tmplog" } + +@test "full flow: verbose mode" { + run bash "$PROJECT_ROOT/src/main.sh" -V pull "$TEST_DIR" + [ "$status" -eq 0 ] + [[ "$output" == *"Configuration"* ]] +} diff --git a/test/test_helper.bash b/test/test_helper.bash index 61f2e68..6df495a 100644 --- a/test/test_helper.bash +++ b/test/test_helper.bash @@ -11,3 +11,5 @@ source "$PROJECT_ROOT/lib/config.sh" source "$PROJECT_ROOT/lib/errors.sh" source "$PROJECT_ROOT/lib/utils.sh" source "$PROJECT_ROOT/lib/version.sh" +source "$PROJECT_ROOT/lib/git.sh" +source "$PROJECT_ROOT/lib/discovery.sh" diff --git a/test/unit/commands_test.bats b/test/unit/commands_test.bats index d9bb279..6f2b467 100644 --- a/test/unit/commands_test.bats +++ b/test/unit/commands_test.bats @@ -1,5 +1,5 @@ #!/usr/bin/env bats -# test/unit/commands_test.bats — Tests for src/commands/hello.sh +# test/unit/commands_test.bats — Tests for GRR commands (pull, status) setup() { PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" @@ -8,11 +8,12 @@ setup() { export LOG_FILE="" } -@test "main.sh --help shows usage" { +@test "main.sh --help shows GRR usage" { run bash "$PROJECT_ROOT/src/main.sh" --help [ "$status" -eq 0 ] - [[ "$output" == *"COMMANDS"* ]] - [[ "$output" == *"hello"* ]] + [[ "$output" == *"GRR"* ]] + [[ "$output" == *"pull"* ]] + [[ "$output" == *"status"* ]] } @test "main.sh --version shows version" { @@ -21,32 +22,88 @@ setup() { [[ "$output" == *"Version:"* ]] } -@test "hello command runs with default name" { - run bash "$PROJECT_ROOT/src/main.sh" hello - [ "$status" -eq 0 ] - [[ "$output" == *"Hello, World!"* ]] +@test "main.sh with no command shows usage" { + run bash "$PROJECT_ROOT/src/main.sh" + [ "$status" -eq 1 ] + [[ "$output" == *"COMMANDS"* ]] +} + +@test "unknown command shows error" { + run bash "$PROJECT_ROOT/src/main.sh" nonexistent + [ "$status" -eq 1 ] + [[ "$output" == *"Unknown command"* ]] } -@test "hello command accepts --name flag" { - run bash "$PROJECT_ROOT/src/main.sh" hello --name "Alice" +@test "pull --help shows pull usage" { + run bash "$PROJECT_ROOT/src/main.sh" pull --help [ "$status" -eq 0 ] - [[ "$output" == *"Hello, Alice!"* ]] + [[ "$output" == *"--fetch"* ]] + [[ "$output" == *"--stash"* ]] + [[ "$output" == *"--parallel"* ]] } -@test "hello --help shows command help" { - run bash "$PROJECT_ROOT/src/main.sh" hello --help +@test "status --help shows status usage" { + run bash "$PROJECT_ROOT/src/main.sh" status --help [ "$status" -eq 0 ] - [[ "$output" == *"--name"* ]] + [[ "$output" == *"--skip"* ]] } -@test "unknown command shows error" { - run bash "$PROJECT_ROOT/src/main.sh" nonexistent - [ "$status" -eq 1 ] - [[ "$output" == *"Unknown command"* ]] +@test "pull on empty directory shows warning" { + local empty_dir + empty_dir=$(mktemp -d) + run bash "$PROJECT_ROOT/src/main.sh" pull "$empty_dir" + [[ "$output" == *"No git repositories"* ]] + rm -rf "$empty_dir" } -@test "no command shows usage" { - run bash "$PROJECT_ROOT/src/main.sh" - [ "$status" -eq 1 ] - [[ "$output" == *"COMMANDS"* ]] +@test "status on empty directory shows warning" { + local empty_dir + empty_dir=$(mktemp -d) + run bash "$PROJECT_ROOT/src/main.sh" status "$empty_dir" + [[ "$output" == *"No git repositories"* ]] + rm -rf "$empty_dir" +} + +@test "pull on nonexistent directory shows error" { + run bash "$PROJECT_ROOT/src/main.sh" pull /nonexistent/path + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "pull --dry-run previews operations" { + # Create a temp repo + local test_dir + test_dir=$(mktemp -d) + mkdir -p "$test_dir/myrepo" + cd "$test_dir/myrepo" + git init -q + git config user.email "test@test.com" + git config user.name "Test" + echo "x" > f.txt && git add . && git commit -q -m "init" + cd - + + run bash "$PROJECT_ROOT/src/main.sh" pull -d --fetch "$test_dir" + [ "$status" -eq 0 ] + [[ "$output" == *"DRY-RUN"* ]] + [[ "$output" == *"myrepo"* ]] + rm -rf "$test_dir" +} + +@test "status shows repo table" { + local test_dir + test_dir=$(mktemp -d) + mkdir -p "$test_dir/myrepo" + cd "$test_dir/myrepo" + git init -q + git config user.email "test@test.com" + git config user.name "Test" + echo "x" > f.txt && git add . && git commit -q -m "init" + cd - + + run bash "$PROJECT_ROOT/src/main.sh" status "$test_dir" + [ "$status" -eq 0 ] + [[ "$output" == *"REPOSITORY"* ]] + [[ "$output" == *"myrepo"* ]] + [[ "$output" == *"clean"* ]] + rm -rf "$test_dir" } diff --git a/test/unit/config_test.bats b/test/unit/config_test.bats index 32aadda..e4c6574 100644 --- a/test/unit/config_test.bats +++ b/test/unit/config_test.bats @@ -10,7 +10,7 @@ setup() { @test "config_load sets defaults" { config_load - [ "$APP_NAME" = "bash-template" ] + [ "$APP_NAME" = "grr" ] [ "$DEBUG" = "false" ] [ "$VERSION" = "dev" ] [ "$LOG_FILE" = "" ] diff --git a/test/unit/discovery_test.bats b/test/unit/discovery_test.bats new file mode 100644 index 0000000..276c8f8 --- /dev/null +++ b/test/unit/discovery_test.bats @@ -0,0 +1,60 @@ +#!/usr/bin/env bats +# test/unit/discovery_test.bats — Tests for lib/discovery.sh + +setup() { + PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export NO_COLOR=1 + export VERBOSE="false" + export LOG_FILE="" + + unset _DISCOVERY_SH_LOADED _LOGGING_SH_LOADED _ERRORS_SH_LOADED + source "$PROJECT_ROOT/lib/logging.sh" + source "$PROJECT_ROOT/lib/errors.sh" + source "$PROJECT_ROOT/lib/discovery.sh" + + # Create temp directory with fake git repos + TEST_DIR=$(mktemp -d) + mkdir -p "$TEST_DIR/repo-a/.git" + mkdir -p "$TEST_DIR/repo-b/.git" + mkdir -p "$TEST_DIR/not-a-repo" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +@test "discover_repos finds git repositories" { + local repos=() + discover_repos "$TEST_DIR" repos + [ "${#repos[@]}" -eq 2 ] +} + +@test "discover_repos returns empty for no repos" { + local empty_dir + empty_dir=$(mktemp -d) + local repos=() + discover_repos "$empty_dir" repos + [ "${#repos[@]}" -eq 0 ] + rm -rf "$empty_dir" +} + +@test "should_skip_repo matches pattern" { + run should_skip_repo "/path/to/test-repo" "*test*" + [ "$status" -eq 0 ] +} + +@test "should_skip_repo does not match non-matching pattern" { + run should_skip_repo "/path/to/myrepo" "*test*" + [ "$status" -eq 1 ] +} + +@test "validate_target_dir fails for missing directory" { + run validate_target_dir "/nonexistent/path" + [ "$status" -eq 1 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "validate_target_dir succeeds for existing directory" { + run validate_target_dir "$TEST_DIR" + [ "$status" -eq 0 ] +} diff --git a/test/unit/git_test.bats b/test/unit/git_test.bats new file mode 100644 index 0000000..9b8e73d --- /dev/null +++ b/test/unit/git_test.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# test/unit/git_test.bats — Tests for lib/git.sh + +setup() { + PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export NO_COLOR=1 + export VERBOSE="false" + export LOG_FILE="" + export TIMEOUT=10 + + # Create a temp git repo for testing + TEST_REPO=$(mktemp -d) + cd "$TEST_REPO" + git init -q + git config user.email "test@test.com" + git config user.name "Test" + echo "init" > file.txt + git add . + git commit -q -m "initial" + + unset _GIT_SH_LOADED _LOGGING_SH_LOADED + source "$PROJECT_ROOT/lib/logging.sh" + source "$PROJECT_ROOT/lib/git.sh" +} + +teardown() { + rm -rf "$TEST_REPO" +} + +@test "git_current_branch returns branch name" { + cd "$TEST_REPO" + run git_current_branch + [ "$status" -eq 0 ] + # Could be master or main depending on git config + [[ "$output" == "master" || "$output" == "main" ]] +} + +@test "git_check_dirty returns 0 for clean repo" { + cd "$TEST_REPO" + run git_check_dirty + [ "$status" -eq 0 ] +} + +@test "git_check_dirty returns 1 for dirty repo" { + cd "$TEST_REPO" + echo "change" >> file.txt + run git_check_dirty + [ "$status" -eq 1 ] + [[ "$output" == *"file.txt"* ]] +} + +@test "git_remote_url returns unknown for no remote" { + cd "$TEST_REPO" + run git_remote_url + [ "$output" = "unknown" ] +} + +@test "git_is_shallow returns false for normal repo" { + cd "$TEST_REPO" + run git_is_shallow + [ "$status" -eq 1 ] # not shallow +} + +@test "git_show_recent_commits shows commits" { + cd "$TEST_REPO" + run git_show_recent_commits 1 + [ "$status" -eq 0 ] + [[ "$output" == *"initial"* ]] +} + +@test "git_stash_changes does not fail on clean repo" { + cd "$TEST_REPO" + run git_stash_changes + [ "$status" -eq 0 ] +} + +@test "git_clean_untracked removes untracked files" { + cd "$TEST_REPO" + echo "junk" > untracked.txt + run git_clean_untracked + [ "$status" -eq 0 ] + [ ! -f "$TEST_REPO/untracked.txt" ] +} + +@test "git_garbage_collect does not fail" { + cd "$TEST_REPO" + run git_garbage_collect + [ "$status" -eq 0 ] +}