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


-
+

-
- 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 ]
+}