diff options
Diffstat (limited to 'docs/handbook/hooks')
| -rw-r--r-- | docs/handbook/hooks/logging-system.md | 492 | ||||
| -rw-r--r-- | docs/handbook/hooks/mirror-configuration.md | 627 | ||||
| -rw-r--r-- | docs/handbook/hooks/notification-system.md | 538 | ||||
| -rw-r--r-- | docs/handbook/hooks/overview.md | 712 | ||||
| -rw-r--r-- | docs/handbook/hooks/post-receive-hook.md | 778 |
5 files changed, 3147 insertions, 0 deletions
diff --git a/docs/handbook/hooks/logging-system.md b/docs/handbook/hooks/logging-system.md new file mode 100644 index 0000000000..5bb0b79e01 --- /dev/null +++ b/docs/handbook/hooks/logging-system.md @@ -0,0 +1,492 @@ +# Logging System + +## Table of Contents + +- [Introduction](#introduction) +- [The log() Function](#the-log-function) + - [Function Signature](#function-signature) + - [Timestamp Generation](#timestamp-generation) + - [Dual Output with tee](#dual-output-with-tee) + - [Graceful Degradation](#graceful-degradation) +- [Log File Configuration](#log-file-configuration) + - [The MIRROR_LOG Variable](#the-mirror_log-variable) + - [Default Path](#default-path) + - [Custom Log Paths](#custom-log-paths) + - [Log File Permissions](#log-file-permissions) +- [Log Message Format](#log-message-format) + - [Timestamp Format](#timestamp-format) + - [Session Boundaries](#session-boundaries) + - [Ref Update Entries](#ref-update-entries) + - [Push Status Entries](#push-status-entries) + - [Summary Block](#summary-block) +- [Complete Log Output Example](#complete-log-output-example) +- [Git Push Output Capture](#git-push-output-capture) +- [Log Rotation](#log-rotation) + - [Using logrotate](#using-logrotate) + - [Manual Rotation](#manual-rotation) + - [Size-Based Rotation](#size-based-rotation) +- [Log Analysis](#log-analysis) + - [Counting Mirror Sessions](#counting-mirror-sessions) + - [Finding Failures](#finding-failures) + - [Extracting Push Duration](#extracting-push-duration) + - [Monitoring with tail](#monitoring-with-tail) +- [Fallback Behavior](#fallback-behavior) +- [Concurrency and Log Interleaving](#concurrency-and-log-interleaving) +- [Security Considerations](#security-considerations) + +--- + +## Introduction + +The Project-Tick `post-receive` hook (`hooks/post-receive`) includes a built-in logging system that records every mirror push operation. The system is implemented as a single bash function, `log()`, that writes timestamped messages to both standard output and a persistent log file. + +The logging system is designed with three priorities: +1. **Reliability** — Logging never causes the hook to fail, even if the log file is unwritable +2. **Visibility** — Messages appear on the pusher's terminal and in the persistent log +3. **Simplicity** — A single function, no external logging frameworks + +--- + +## The log() Function + +### Function Signature + +```bash +log() { + local timestamp + timestamp="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$timestamp] $*" +} +``` + +The function accepts any number of string arguments via `$*`, which concatenates them with a space separator. + +**Usage examples** from the hook: + +```bash +log "=== Mirror push triggered ===" +log " ref: $refname ($oldrev -> $newrev)" +log "Pushing to remote: $remote" +log " ✓ Successfully pushed to $remote" +log " ✗ FAILED to push to $remote" +log "--- Summary ---" +log " Succeeded: ${SUCCEEDED_REMOTES[*]:-none}" +log " Failed: ${FAILED_REMOTES[*]:-none}" +log "=== Finished with errors ===" +log "=== Finished successfully ===" +``` + +### Timestamp Generation + +```bash +local timestamp +timestamp="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" +``` + +The `date` command is invoked with two key options: + +| Option | Purpose | +|--------|---------| +| `-u` | Use UTC time regardless of the server's local timezone | +| `'+%Y-%m-%d %H:%M:%S UTC'` | ISO 8601-inspired format with explicit UTC suffix | + +**Why UTC?** Server environments may span multiple time zones. UTC ensures all log entries are comparable without timezone conversion. The explicit `UTC` suffix in the format string makes it unambiguous — a reader seeing `[2026-04-05 14:30:00 UTC]` knows this is not local time. + +**Why `local`?** The `local` keyword restricts `timestamp` to the function scope. Without it, `timestamp` would be a global variable, persisting after the function returns and potentially conflicting with other variables. + +### Dual Output with tee + +```bash +echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null +``` + +The `tee` command reads from stdin and writes to both stdout and the specified file: + +``` +echo "message" ──► tee ──► stdout (pusher's terminal) + │ + └──► $MIRROR_LOG (append mode) +``` + +The `-a` flag means **append**. Without it, `tee` would truncate the log file on each write, losing previous entries. + +### Graceful Degradation + +```bash +echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$timestamp] $*" +``` + +The error handling chain works in three stages: + +1. **Primary path**: `echo | tee -a "$MIRROR_LOG"` — write to both stdout and log file +2. **Error suppression**: `2>/dev/null` — if `tee` can't write to the log file, suppress its error message (e.g., "Permission denied") +3. **Fallback**: `|| echo "[$timestamp] $*"` — if the entire `echo | tee` pipeline fails, write to stdout only + +This means the log function **never fails silently** — even if the log file is inaccessible, the message still reaches the pusher's terminal. And it **never crashes the hook** — log file errors don't propagate despite `set -e` (because they're handled by the `||` fallback). + +--- + +## Log File Configuration + +### The MIRROR_LOG Variable + +```bash +MIRROR_LOG="${MIRROR_LOG:-/var/log/git-mirror.log}" +``` + +The log file path is controlled by the `MIRROR_LOG` environment variable with a default fallback. + +### Default Path + +The default log file location is `/var/log/git-mirror.log`. This follows the Linux Filesystem Hierarchy Standard (FHS) convention of placing log files under `/var/log/`. + +**Requirements for the default path**: +- The directory `/var/log/` must exist (it always does on standard Linux systems) +- The user running the git daemon must have write permission to the file +- The file will be created if it doesn't exist (assuming directory write permission) + +### Custom Log Paths + +Override the default by setting `MIRROR_LOG` in the hook's environment: + +```bash +# In the git daemon's environment +export MIRROR_LOG=/var/log/project-tick/mirror.log + +# Or per-repository via a wrapper +MIRROR_LOG=/home/git/logs/mirror.log hooks/post-receive +``` + +**Common custom paths**: + +| Path | Use Case | +|------|----------| +| `/var/log/git-mirror.log` | Default — shared system log | +| `/var/log/project-tick/mirror.log` | Project-specific log directory | +| `/home/git/logs/mirror.log` | User-local log (no root needed) | +| `/tmp/mirror.log` | Temporary/testing | +| `/dev/null` | Disable file logging (stdout only) | + +### Log File Permissions + +Set up the log file with appropriate ownership: + +```bash +# Create the log file with correct ownership +sudo touch /var/log/git-mirror.log +sudo chown git:git /var/log/git-mirror.log +sudo chmod 640 /var/log/git-mirror.log + +# Or create a project-specific log directory +sudo mkdir -p /var/log/project-tick +sudo chown git:git /var/log/project-tick +sudo chmod 750 /var/log/project-tick +``` + +The `640` permission (`rw-r-----`) allows the git user to write and the git group to read, while preventing other users from accessing potentially sensitive information. + +--- + +## Log Message Format + +### Timestamp Format + +Every log line follows this pattern: + +``` +[YYYY-MM-DD HH:MM:SS UTC] <message> +``` + +Example: +``` +[2026-04-05 14:30:00 UTC] === Mirror push triggered === +``` + +The square brackets delimit the timestamp, making it easy to parse programmatically: + +```bash +# Extract just the messages (remove timestamps) +sed 's/^\[[^]]*\] //' /var/log/git-mirror.log + +# Extract just the timestamps +grep -oP '^\[\K[^]]+' /var/log/git-mirror.log +``` + +### Session Boundaries + +Each mirror operation is delimited by banner lines: + +``` +[2026-04-05 14:30:00 UTC] === Mirror push triggered === +... +[2026-04-05 14:30:15 UTC] === Finished successfully === +``` + +Or on failure: + +``` +[2026-04-05 14:30:00 UTC] === Mirror push triggered === +... +[2026-04-05 14:30:15 UTC] === Finished with errors === +``` + +The `===` delimiters serve as visual and programmatic session markers. + +### Ref Update Entries + +Each ref in the push is logged with indentation: + +``` +[2026-04-05 14:30:00 UTC] ref: refs/heads/main (abc1234 -> def5678) +[2026-04-05 14:30:00 UTC] ref: refs/tags/v1.0.0 (0000000 -> abc1234) +``` + +The format `($oldrev -> $newrev)` shows the transition. The all-zeros SHA (`0000000...`) indicates a new ref creation or deletion: + +| Pattern | Meaning | +|---------|---------| +| `(000... -> abc...)` | New ref created | +| `(abc... -> def...)` | Ref updated | +| `(abc... -> 000...)` | Ref deleted | + +### Push Status Entries + +Each remote push produces a status line: + +``` +[2026-04-05 14:30:05 UTC] Pushing to remote: github +[2026-04-05 14:30:08 UTC] ✓ Successfully pushed to github +``` + +Or on failure: + +``` +[2026-04-05 14:30:10 UTC] Pushing to remote: sourceforge +[2026-04-05 14:30:25 UTC] ✗ FAILED to push to sourceforge +``` + +The Unicode symbols (✓ and ✗) provide quick visual scanning in the log. + +### Summary Block + +At the end of each session: + +``` +[2026-04-05 14:30:15 UTC] --- Summary --- +[2026-04-05 14:30:15 UTC] Succeeded: github gitlab codeberg +[2026-04-05 14:30:15 UTC] Failed: sourceforge +``` + +Or when all succeed: + +``` +[2026-04-05 14:30:15 UTC] --- Summary --- +[2026-04-05 14:30:15 UTC] Succeeded: github gitlab codeberg sourceforge +[2026-04-05 14:30:15 UTC] Failed: none +``` + +--- + +## Complete Log Output Example + +A typical successful mirror operation produces: + +``` +[2026-04-05 14:30:00 UTC] === Mirror push triggered === +[2026-04-05 14:30:00 UTC] ref: refs/heads/main (a1b2c3d4e5f6 -> f6e5d4c3b2a1) +[2026-04-05 14:30:00 UTC] ref: refs/tags/v2.1.0 (0000000000000000000000000000000000000000 -> a1b2c3d4e5f6) +[2026-04-05 14:30:00 UTC] Pushing to remote: github +To github.com:Project-Tick/Project-Tick.git + + a1b2c3d..f6e5d4c main -> main (forced update) + * [new tag] v2.1.0 -> v2.1.0 +[2026-04-05 14:30:03 UTC] ✓ Successfully pushed to github +[2026-04-05 14:30:03 UTC] Pushing to remote: gitlab +To gitlab.com:Project-Tick/Project-Tick.git + + a1b2c3d..f6e5d4c main -> main (forced update) + * [new tag] v2.1.0 -> v2.1.0 +[2026-04-05 14:30:06 UTC] ✓ Successfully pushed to gitlab +[2026-04-05 14:30:06 UTC] Pushing to remote: codeberg +To codeberg.org:Project-Tick/Project-Tick.git + + a1b2c3d..f6e5d4c main -> main (forced update) + * [new tag] v2.1.0 -> v2.1.0 +[2026-04-05 14:30:09 UTC] ✓ Successfully pushed to codeberg +[2026-04-05 14:30:09 UTC] --- Summary --- +[2026-04-05 14:30:09 UTC] Succeeded: github gitlab codeberg +[2026-04-05 14:30:09 UTC] Failed: none +[2026-04-05 14:30:09 UTC] === Finished successfully === +``` + +Note that the raw `git push` output (the `To ...` and `+ ... (forced update)` lines) is **interleaved** with the hook's log messages. This is because `git push` output goes through `tee` to the log file alongside the hook's `log()` calls. + +--- + +## Git Push Output Capture + +Beyond the `log()` function's messages, the raw output of each `git push` is also captured: + +```bash +git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null +``` + +The `2>&1` redirect merges git's stderr into stdout before piping to `tee`. Git sends progress messages and transfer statistics to stderr, so this redirect ensures the complete output is logged: + +``` +To github.com:Project-Tick/Project-Tick.git + + a1b2c3d..f6e5d4c main -> main (forced update) + * [new tag] v2.1.0 -> v2.1.0 +``` + +This raw output appears in the log file **without timestamps** because it bypasses the `log()` function. It sits between the "Pushing to remote:" and "✓ Successfully pushed" entries. + +--- + +## Log Rotation + +The hook appends to the log file indefinitely. Without rotation, the file will grow without bound. Here are strategies for managing log file size. + +### Using logrotate + +Create `/etc/logrotate.d/git-mirror`: + +``` +/var/log/git-mirror.log { + weekly + rotate 12 + compress + delaycompress + missingok + notifempty + create 640 git git +} +``` + +| Directive | Effect | +|-----------|--------| +| `weekly` | Rotate once per week | +| `rotate 12` | Keep 12 rotated files (3 months) | +| `compress` | Compress rotated files with gzip | +| `delaycompress` | Don't compress the most recent rotated file | +| `missingok` | Don't error if the log file doesn't exist | +| `notifempty` | Don't rotate if the file is empty | +| `create 640 git git` | Create new log file with these permissions | + +### Manual Rotation + +```bash +# Rotate manually +mv /var/log/git-mirror.log /var/log/git-mirror.log.1 +touch /var/log/git-mirror.log +chown git:git /var/log/git-mirror.log +``` + +No signal or restart is needed — the hook appends to `$MIRROR_LOG` on each invocation, so it will create a new file if the old one was moved. + +### Size-Based Rotation + +Add a cron job that rotates when the file exceeds a certain size: + +```bash +# /etc/cron.daily/git-mirror-log-rotate +#!/bin/sh +LOG=/var/log/git-mirror.log +MAX_SIZE=10485760 # 10 MB + +if [ -f "$LOG" ]; then + SIZE=$(stat -c %s "$LOG" 2>/dev/null || echo 0) + if [ "$SIZE" -gt "$MAX_SIZE" ]; then + mv "$LOG" "${LOG}.$(date +%Y%m%d)" + gzip "${LOG}.$(date +%Y%m%d)" + fi +fi +``` + +--- + +## Log Analysis + +### Counting Mirror Sessions + +```bash +grep -c "=== Mirror push triggered ===" /var/log/git-mirror.log +``` + +### Finding Failures + +```bash +# Find all failure entries +grep "✗ FAILED" /var/log/git-mirror.log + +# Find sessions that ended with errors +grep "=== Finished with errors ===" /var/log/git-mirror.log + +# Count failures per remote +grep "✗ FAILED" /var/log/git-mirror.log | awk '{print $NF}' | sort | uniq -c | sort -rn +``` + +### Extracting Push Duration + +Calculate the time between trigger and finish: + +```bash +# Extract session start and end times +grep -E "(Mirror push triggered|Finished)" /var/log/git-mirror.log +``` + +### Monitoring with tail + +For real-time monitoring during a push: + +```bash +tail -f /var/log/git-mirror.log +``` + +--- + +## Fallback Behavior + +The logging system handles the following failure scenarios: + +| Scenario | Behavior | +|----------|----------| +| Log file doesn't exist | `tee` creates it (if directory is writable) | +| Log file is not writable | `tee` error suppressed; message goes to stdout only | +| Log directory doesn't exist | `tee` fails silently; message goes to stdout only | +| `/dev/null` as log path | All file output discarded; stdout works normally | +| `$MIRROR_LOG` is empty | `tee -a ""` fails; fallback echo to stdout | + +In every case, the hook continues to function. Logging is strictly best-effort and never causes a hook failure. + +--- + +## Concurrency and Log Interleaving + +When multiple pushes trigger the hook simultaneously, multiple `post-receive` instances write to the same log file concurrently. The `-a` (append) flag on `tee` uses `O_APPEND` semantics, which means writes are atomic at the kernel level for sizes up to `PIPE_BUF` (4096 bytes on Linux). + +Since individual log lines are well under 4096 bytes, **individual lines will not be corrupted**. However, lines from different sessions may interleave: + +``` +[2026-04-05 14:30:00 UTC] === Mirror push triggered === # Session A +[2026-04-05 14:30:00 UTC] === Mirror push triggered === # Session B +[2026-04-05 14:30:00 UTC] ref: refs/heads/main (...) # Session A +[2026-04-05 14:30:00 UTC] ref: refs/heads/feature (...) # Session B +``` + +To disambiguate, you could modify the `log()` function to include a PID: + +```bash +echo "[$$][$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$$][$timestamp] $*" +``` + +This produces lines like `[12345][2026-04-05 14:30:00 UTC] message` which can be filtered by PID. + +--- + +## Security Considerations + +1. **Log file contents** — The log records ref names, remote names, and git push output. It should **not** contain credentials (tokens are in the git config, not in push output). However, treat the log as moderately sensitive. + +2. **Log file permissions** — Use `640` or `600` permissions. Avoid world-readable (`644`) logs on multi-user systems. + +3. **Log injection** — Ref names come from the pusher and appear in log messages (`log " ref: $refname ..."`). While this is a cosmetic concern (log files aren't executed), extremely long or crafted ref names could produce misleading log entries. Git itself limits ref names to valid characters. + +4. **Disk exhaustion** — Without log rotation, the log file grows indefinitely. A hostile actor with push access could trigger many pushes to fill the disk. Use log rotation and monitoring to mitigate. diff --git a/docs/handbook/hooks/mirror-configuration.md b/docs/handbook/hooks/mirror-configuration.md new file mode 100644 index 0000000000..2bf584e861 --- /dev/null +++ b/docs/handbook/hooks/mirror-configuration.md @@ -0,0 +1,627 @@ +# Mirror Configuration + +## Table of Contents + +- [Introduction](#introduction) +- [How Git Mirroring Works](#how-git-mirroring-works) + - [Push Mirroring vs Fetch Mirroring](#push-mirroring-vs-fetch-mirroring) + - [Ref Namespaces Synchronized](#ref-namespaces-synchronized) + - [The --mirror Flag Internals](#the---mirror-flag-internals) + - [The --force Flag and Divergent History](#the---force-flag-and-divergent-history) +- [Mirror Remote Configuration](#mirror-remote-configuration) + - [Adding a Mirror Remote](#adding-a-mirror-remote) + - [Listing Configured Remotes](#listing-configured-remotes) + - [Modifying a Remote URL](#modifying-a-remote-url) + - [Removing a Mirror Remote](#removing-a-mirror-remote) +- [Supported Protocols](#supported-protocols) + - [SSH Protocol](#ssh-protocol) + - [HTTPS Protocol](#https-protocol) + - [Git Protocol](#git-protocol) + - [Local Path Protocol](#local-path-protocol) +- [Forge-Specific Configuration](#forge-specific-configuration) + - [GitHub](#github) + - [GitLab](#gitlab) + - [Codeberg](#codeberg) + - [SourceForge](#sourceforge) + - [Bitbucket](#bitbucket) + - [Gitea (Self-Hosted)](#gitea-self-hosted) +- [Authentication Setup](#authentication-setup) + - [SSH Key Authentication](#ssh-key-authentication) + - [HTTPS Token Authentication](#https-token-authentication) + - [SSH Config for Multiple Keys](#ssh-config-for-multiple-keys) + - [Token Scopes and Permissions](#token-scopes-and-permissions) +- [The MIRROR_REMOTES Variable](#the-mirror_remotes-variable) + - [Auto-Detection Mode](#auto-detection-mode) + - [Explicit Remote List](#explicit-remote-list) + - [Excluding Specific Remotes](#excluding-specific-remotes) +- [Git Config File Format](#git-config-file-format) +- [Multi-Repository Mirroring](#multi-repository-mirroring) +- [Troubleshooting Mirror Issues](#troubleshooting-mirror-issues) + +--- + +## Introduction + +The Project-Tick `post-receive` hook (`hooks/post-receive`) mirrors the canonical bare repository to multiple forge platforms. This document covers the configuration of mirror remotes — how to set them up, what protocols and authentication methods are supported, and how the hook discovers and uses them. + +The mirror push is triggered by the following line in the hook: + +```bash +git push --mirror --force "$remote" +``` + +Everything in this document revolves around configuring the `$remote` targets that this command pushes to. + +--- + +## How Git Mirroring Works + +### Push Mirroring vs Fetch Mirroring + +Git supports two mirroring directions: + +| Type | Command | Direction | +|------|---------|-----------| +| Push mirror | `git push --mirror <remote>` | Local → Remote | +| Fetch mirror | `git clone --mirror <url>` | Remote → Local | + +The Project-Tick hook uses **push mirroring** — the canonical repository pushes its refs outward to each forge. This is the active/upstream pattern: changes flow from one source to many targets. + +The opposite approach, fetch mirroring, would require each forge to periodically pull from the canonical repo. Push mirroring is preferred because it provides immediate synchronization without polling latency. + +### Ref Namespaces Synchronized + +When `git push --mirror` executes, it synchronizes **all** refs under the `refs/` hierarchy: + +| Ref Namespace | Contents | Example | +|---------------|----------|---------| +| `refs/heads/*` | Branches | `refs/heads/main`, `refs/heads/feature/x` | +| `refs/tags/*` | Tags | `refs/tags/v1.0.0`, `refs/tags/v2.0.0-rc1` | +| `refs/notes/*` | Git notes | `refs/notes/commits` | +| `refs/replace/*` | Replacement objects | `refs/replace/<sha>` | +| `refs/meta/*` | Metadata refs (Gerrit) | `refs/meta/config` | + +Notably, `--mirror` also **deletes** remote refs that no longer exist locally. If a branch `feature/old` is deleted in the canonical repo, the mirror push removes it from all mirrors. + +### The --mirror Flag Internals + +Under the hood, `git push --mirror` is equivalent to: + +```bash +git push --force --prune <remote> 'refs/*:refs/*' +``` + +This refspec (`refs/*:refs/*`) maps every local ref to the same-named remote ref. The `--prune` flag deletes remote refs that have no local counterpart. + +### The --force Flag and Divergent History + +The `--force` flag in the hook's `git push --mirror --force` is redundant with `--mirror` (which implies force), but it's included explicitly for clarity. It handles the case where a ref on the canonical repo has been rewritten (e.g., via `git push --force` or `git rebase`), which would otherwise be rejected as a non-fast-forward update: + +``` + ! [rejected] main -> main (non-fast-forward) +``` + +With `--force`, the mirror is overwritten to match the canonical state exactly, even if that means losing remote-only history. + +--- + +## Mirror Remote Configuration + +### Adding a Mirror Remote + +From within the bare repository: + +```bash +cd /path/to/project-tick.git +git remote add <name> <url> +``` + +The `<name>` can be any valid git remote name. The hook auto-discovers all remotes that aren't named `origin`: + +```bash +MIRROR_REMOTES=$(git remote | grep -v '^origin$' || true) +``` + +**Convention**: Use the forge name as the remote name: + +```bash +git remote add github git@github.com:Project-Tick/Project-Tick.git +git remote add gitlab git@gitlab.com:Project-Tick/Project-Tick.git +git remote add codeberg git@codeberg.org:Project-Tick/Project-Tick.git +``` + +### Listing Configured Remotes + +```bash +git remote -v +``` + +Output: +``` +codeberg git@codeberg.org:Project-Tick/Project-Tick.git (fetch) +codeberg git@codeberg.org:Project-Tick/Project-Tick.git (push) +github git@github.com:Project-Tick/Project-Tick.git (fetch) +github git@github.com:Project-Tick/Project-Tick.git (push) +gitlab git@gitlab.com:Project-Tick/Project-Tick.git (fetch) +gitlab git@gitlab.com:Project-Tick/Project-Tick.git (push) +origin /srv/git/project-tick.git (fetch) +origin /srv/git/project-tick.git (push) +``` + +### Modifying a Remote URL + +```bash +git remote set-url github https://x-access-token:NEW_TOKEN@github.com/Project-Tick/Project-Tick.git +``` + +### Removing a Mirror Remote + +```bash +git remote remove codeberg +``` + +The hook will no longer push to Codeberg on subsequent pushes. This is the recommended way to temporarily or permanently disable a mirror. + +--- + +## Supported Protocols + +### SSH Protocol + +**URL format**: +``` +git@<host>:<owner>/<repo>.git +ssh://<user>@<host>/<path> +``` + +**Examples**: +```bash +git remote add github git@github.com:Project-Tick/Project-Tick.git +git remote add sourceforge ssh://USERNAME@git.code.sf.net/p/project-tick/code +``` + +**Characteristics**: +- Authenticated via SSH keypair +- Supports key-based automation +- Port 22 by default (or custom via `ssh://host:port/path`) +- Requires public key registration on the forge + +**Best for**: Server-side automation where SSH keys can be managed securely. + +### HTTPS Protocol + +**URL format**: +``` +https://<user>:<token>@<host>/<owner>/<repo>.git +``` + +**Examples**: +```bash +git remote add github https://x-access-token:TOKEN@github.com/Project-Tick/Project-Tick.git +git remote add gitlab https://oauth2:TOKEN@gitlab.com/Project-Tick/Project-Tick.git +git remote add codeberg https://TOKEN@codeberg.org/Project-Tick/Project-Tick.git +``` + +**Characteristics**: +- Token embedded in URL (stored in git config) +- Works behind HTTP proxies +- Port 443 by default +- No SSH key management needed + +**Best for**: Environments where SSH is blocked or key management is impractical. + +### Git Protocol + +**URL format**: +``` +git://<host>/<path> +``` + +**Characteristics**: +- Read-only — **cannot** be used for mirroring +- Unauthenticated +- Port 9418 + +Not suitable for the mirror hook. + +### Local Path Protocol + +**URL format**: +``` +/path/to/repo.git +file:///path/to/repo.git +``` + +**Characteristics**: +- No network involved +- Useful for local backup mirrors +- Fast — uses hardlinks when possible + +**Example**: +```bash +git remote add backup /mnt/backup/project-tick.git +``` + +--- + +## Forge-Specific Configuration + +### GitHub + +**SSH remote**: +```bash +git remote add github git@github.com:Project-Tick/Project-Tick.git +``` + +**HTTPS remote**: +```bash +git remote add github https://x-access-token:ghp_XXXX@github.com/Project-Tick/Project-Tick.git +``` + +**Token format**: GitHub Personal Access Tokens start with `ghp_` (classic) or `github_pat_` (fine-grained). + +**Required token scopes**: +- `repo` (full control of private repositories) — for classic tokens +- Repository permissions → Contents → Read and Write — for fine-grained tokens + +**GitHub-specific considerations**: +- GitHub may reject pushes that include non-standard refs (e.g., `refs/pull/*` or `refs/keep-around/*`). Since these refs don't exist in the canonical repo, this is typically not an issue. +- Branch protection rules may block `--force` pushes. Ensure the mirror token has admin access or that branch protection allows force pushes from the mirror user. +- GitHub has a push size limit of 2 GB per push. Large mirror pushes may need to be split. + +### GitLab + +**SSH remote**: +```bash +git remote add gitlab git@gitlab.com:Project-Tick/Project-Tick.git +``` + +**HTTPS remote**: +```bash +git remote add gitlab https://oauth2:glpat-XXXX@gitlab.com/Project-Tick/Project-Tick.git +``` + +**Token format**: GitLab Personal Access Tokens start with `glpat-`. + +**Required token scopes**: +- `write_repository` — for push access + +**GitLab-specific considerations**: +- GitLab supports built-in repository mirroring (Settings → Repository → Mirroring). This is an alternative to the hook-based approach but requires GitLab Premium for push mirroring. +- Protected branches/tags may reject force pushes. Configure the mirror user as a Maintainer with force-push permissions. + +### Codeberg + +**SSH remote**: +```bash +git remote add codeberg git@codeberg.org:Project-Tick/Project-Tick.git +``` + +**HTTPS remote**: +```bash +git remote add codeberg https://TOKEN@codeberg.org/Project-Tick/Project-Tick.git +``` + +**Token format**: Codeberg (Gitea) uses application tokens without a specific prefix. + +**Required token permissions**: +- Repository → Write + +**Codeberg-specific considerations**: +- Codeberg runs Gitea/Forgejo. Token authentication uses the token as the username with no password (or any dummy password). +- Codeberg also supports Gitea's built-in mirror feature. + +### SourceForge + +**SSH remote** (SSH only — no HTTPS push support): +```bash +git remote add sourceforge ssh://USERNAME@git.code.sf.net/p/project-tick/code +``` + +**SourceForge-specific considerations**: +- SourceForge uses a non-standard URL format: `ssh://USERNAME@git.code.sf.net/p/<project>/<mount>` +- The `USERNAME` is the SourceForge account username +- SSH key must be registered at https://sourceforge.net/auth/shell_services +- SourceForge may have rate limits on Git operations + +### Bitbucket + +**SSH remote**: +```bash +git remote add bitbucket git@bitbucket.org:Project-Tick/Project-Tick.git +``` + +**HTTPS remote**: +```bash +git remote add bitbucket https://USERNAME:APP_PASSWORD@bitbucket.org/Project-Tick/Project-Tick.git +``` + +**Bitbucket-specific considerations**: +- Bitbucket uses App Passwords (not PATs) for HTTPS authentication +- The username is the Bitbucket account username (not email) + +### Gitea (Self-Hosted) + +**SSH remote**: +```bash +git remote add gitea git@gitea.example.com:Project-Tick/Project-Tick.git +``` + +**HTTPS remote**: +```bash +git remote add gitea https://TOKEN@gitea.example.com/Project-Tick/Project-Tick.git +``` + +**Considerations**: +- Self-hosted Gitea instances may use custom SSH ports: `ssh://git@gitea.example.com:2222/Project-Tick/Project-Tick.git` +- Self-signed TLS certificates require `git config http.sslVerify false` on the bare repo (not recommended for production) + +--- + +## Authentication Setup + +### SSH Key Authentication + +Generate a dedicated mirror key: + +```bash +ssh-keygen -t ed25519 -C "project-tick-mirror" -f ~/.ssh/mirror_key -N "" +``` + +Register the public key (`~/.ssh/mirror_key.pub`) as a deploy key on each forge: + +| Forge | Registration Path | +|-------|------------------| +| GitHub | Repository → Settings → Deploy keys | +| GitLab | Repository → Settings → Repository → Deploy keys | +| Codeberg | Repository → Settings → Deploy Keys | +| SourceForge | Account → SSH Settings | + +### HTTPS Token Authentication + +Tokens are embedded directly in the remote URL as documented in each forge's section above. The token is stored in the bare repository's git config file: + +```bash +cat /path/to/project-tick.git/config +``` + +```ini +[remote "github"] + url = https://x-access-token:ghp_XXXX@github.com/Project-Tick/Project-Tick.git + fetch = +refs/*:refs/remotes/github/* +``` + +**Security note**: Ensure the config file has restrictive permissions: + +```bash +chmod 600 /path/to/project-tick.git/config +``` + +### SSH Config for Multiple Keys + +When different forges require different SSH keys, use `~/.ssh/config`: + +``` +Host github.com + HostName github.com + User git + IdentityFile ~/.ssh/github_mirror_key + IdentitiesOnly yes + +Host gitlab.com + HostName gitlab.com + User git + IdentityFile ~/.ssh/gitlab_mirror_key + IdentitiesOnly yes + +Host codeberg.org + HostName codeberg.org + User git + IdentityFile ~/.ssh/codeberg_mirror_key + IdentitiesOnly yes + +Host git.code.sf.net + HostName git.code.sf.net + User USERNAME + IdentityFile ~/.ssh/sourceforge_mirror_key + IdentitiesOnly yes +``` + +The `IdentitiesOnly yes` directive ensures only the specified key is offered, preventing SSH from trying all loaded keys. + +### Token Scopes and Permissions + +Minimum required permissions for each forge: + +| Forge | Scope/Permission | Allows | +|-------|-----------------|--------| +| GitHub (classic PAT) | `repo` | Push to public and private repos | +| GitHub (fine-grained) | Contents: Read and Write | Push to the specific repo | +| GitLab | `write_repository` | Push to the repo | +| Codeberg | Repository: Write | Push to the repo | +| Bitbucket | Repositories: Write | Push to the repo | + +**Principle of least privilege**: Use fine-grained tokens scoped to a single repository when possible. Avoid tokens with admin or organizational permissions. + +--- + +## The MIRROR_REMOTES Variable + +### Auto-Detection Mode + +When `MIRROR_REMOTES` is not set (the default), the hook auto-detects remotes: + +```bash +MIRROR_REMOTES="${MIRROR_REMOTES:-}" + +if [[ -z "$MIRROR_REMOTES" ]]; then + MIRROR_REMOTES=$(git remote | grep -v '^origin$' || true) +fi +``` + +This discovers all remotes except `origin`. The assumption is: +- `origin` refers to the canonical repo itself (if configured) +- Everything else is a mirror target + +### Explicit Remote List + +Override auto-detection by setting `MIRROR_REMOTES`: + +```bash +export MIRROR_REMOTES="github gitlab" +``` + +This restricts mirroring to only the named remotes, ignoring others like `codeberg` or `sourceforge`. Useful for: +- Temporarily disabling a problematic mirror +- Phased rollouts of new mirrors +- Testing a single mirror + +### Excluding Specific Remotes + +There is no built-in exclude mechanism, but you can achieve it by explicitly listing the desired remotes: + +```bash +# Mirror to everything except sourceforge +export MIRROR_REMOTES="github gitlab codeberg" +``` + +Alternatively, modify the auto-detection grep to exclude additional patterns: + +```bash +MIRROR_REMOTES=$(git remote | grep -v -e '^origin$' -e '^sourceforge$' || true) +``` + +--- + +## Git Config File Format + +The mirror remotes are stored in the bare repository's `config` file. The relevant sections look like: + +```ini +[remote "origin"] + url = /srv/git/project-tick.git + fetch = +refs/*:refs/remotes/origin/* + +[remote "github"] + url = git@github.com:Project-Tick/Project-Tick.git + fetch = +refs/*:refs/remotes/github/* + +[remote "gitlab"] + url = git@gitlab.com:Project-Tick/Project-Tick.git + fetch = +refs/*:refs/remotes/gitlab/* + +[remote "codeberg"] + url = git@codeberg.org:Project-Tick/Project-Tick.git + fetch = +refs/*:refs/remotes/codeberg/* + +[remote "sourceforge"] + url = ssh://USERNAME@git.code.sf.net/p/project-tick/code + fetch = +refs/*:refs/remotes/sourceforge/* +``` + +You can edit this file directly instead of using `git remote add`: + +```bash +vim /path/to/project-tick.git/config +``` + +However, `git remote add` is preferred because it ensures correct syntax and creates the appropriate fetch refspec. + +--- + +## Multi-Repository Mirroring + +If Project-Tick manages multiple bare repositories (e.g., separate repos for subprojects), the same hook script can be deployed to each: + +```bash +for repo in /srv/git/*.git; do + cp hooks/post-receive "$repo/hooks/post-receive" + chmod +x "$repo/hooks/post-receive" +done +``` + +Each repository needs its own mirror remotes configured: + +```bash +cd /srv/git/project-tick.git +git remote add github git@github.com:Project-Tick/Project-Tick.git + +cd /srv/git/sub-project.git +git remote add github git@github.com:Project-Tick/sub-project.git +``` + +The hook's auto-detection ensures each repository mirrors to its own set of remotes without shared configuration. + +--- + +## Troubleshooting Mirror Issues + +### Remote URL Typo + +**Symptom**: `fatal: 'github' does not appear to be a git repository` + +**Fix**: Verify the remote URL: +```bash +git remote get-url github +``` + +### SSH Host Key Verification Failed + +**Symptom**: `Host key verification failed.` + +**Fix**: Add the host key to known_hosts: +```bash +ssh-keyscan github.com >> ~/.ssh/known_hosts +ssh-keyscan gitlab.com >> ~/.ssh/known_hosts +ssh-keyscan codeberg.org >> ~/.ssh/known_hosts +ssh-keyscan git.code.sf.net >> ~/.ssh/known_hosts +``` + +### HTTPS Token Expired + +**Symptom**: `fatal: Authentication failed for 'https://...'` + +**Fix**: Update the token in the remote URL: +```bash +git remote set-url github https://x-access-token:NEW_TOKEN@github.com/Project-Tick/Project-Tick.git +``` + +### Force Push Rejected by Branch Protection + +**Symptom**: `! [remote rejected] main -> main (protected branch hook declined)` + +**Fix**: On the forge, either: +1. Grant the mirror user admin/bypass permissions +2. Add the mirror user/key to the force-push allowlist +3. Disable branch protection for the mirror repository (if appropriate) + +### Push Size Limit Exceeded + +**Symptom**: `fatal: the remote end hung up unexpectedly` (during large pushes) + +**Fix**: Increase Git's buffer sizes: +```bash +git config http.postBuffer 524288000 # 500 MB +``` + +Or perform an initial manual push before enabling automated mirroring. + +### SSH Agent Not Available + +**Symptom**: `Permission denied (publickey).` + +**Fix**: Ensure the SSH key is loaded or use `IdentityFile` in SSH config: +```bash +ssh-add ~/.ssh/mirror_key +# or configure ~/.ssh/config with IdentityFile +``` + +### Network Timeout + +**Symptom**: `fatal: unable to access '...': Failed to connect to ... port 443: Connection timed out` + +**Fix**: Check network connectivity, proxy settings, and firewall rules. Consider setting a git timeout: +```bash +git config http.lowSpeedLimit 1000 +git config http.lowSpeedTime 300 +``` diff --git a/docs/handbook/hooks/notification-system.md b/docs/handbook/hooks/notification-system.md new file mode 100644 index 0000000000..a642d5343f --- /dev/null +++ b/docs/handbook/hooks/notification-system.md @@ -0,0 +1,538 @@ +# Notification System + +## Table of Contents + +- [Introduction](#introduction) +- [Notification Architecture](#notification-architecture) +- [Email Notification Implementation](#email-notification-implementation) + - [Trigger Conditions](#trigger-conditions) + - [Prerequisite Check](#prerequisite-check) + - [Email Body Construction](#email-body-construction) + - [Subject Line Format](#subject-line-format) + - [Recipient Configuration](#recipient-configuration) +- [The MIRROR_NOTIFY Variable](#the-mirror_notify-variable) + - [Enabling Notifications](#enabling-notifications) + - [Disabling Notifications](#disabling-notifications) + - [Multiple Recipients](#multiple-recipients) +- [Email Body Format](#email-body-format) + - [Complete Email Example](#complete-email-example) + - [Field-by-Field Breakdown](#field-by-field-breakdown) +- [Mail Command Integration](#mail-command-integration) + - [The mail Command](#the-mail-command) + - [Installing mail on Different Systems](#installing-mail-on-different-systems) + - [Mail Transfer Agent Configuration](#mail-transfer-agent-configuration) + - [Testing Email Delivery](#testing-email-delivery) +- [Failure Scenarios and Edge Cases](#failure-scenarios-and-edge-cases) +- [Extending the Notification System](#extending-the-notification-system) + - [Adding Webhook Notifications](#adding-webhook-notifications) + - [Adding Slack Integration](#adding-slack-integration) + - [Adding Discord Integration](#adding-discord-integration) + - [Adding Matrix Integration](#adding-matrix-integration) + - [Adding SMS Notifications](#adding-sms-notifications) +- [Notification Suppression](#notification-suppression) +- [Monitoring and Alerting Integration](#monitoring-and-alerting-integration) + +--- + +## Introduction + +The Project-Tick `post-receive` hook (`hooks/post-receive`) includes an optional email notification system that alerts administrators when mirror push operations fail. The system is triggered only on failure and only when explicitly configured via the `MIRROR_NOTIFY` environment variable. + +The notification system follows two guiding principles: +1. **Opt-in** — Notifications are disabled by default; no email is sent unless `MIRROR_NOTIFY` is set +2. **Graceful degradation** — If the `mail` command is not available, the notification is silently skipped + +--- + +## Notification Architecture + +The notification flow is: + +``` +Mirror push loop completes + │ + ▼ +Any FAILED_REMOTES? ──No──► Skip notification + │ + Yes + │ + ▼ +MIRROR_NOTIFY set? ──No──► Skip notification + │ + Yes + │ + ▼ +mail command available? ──No──► Skip notification + │ + Yes + │ + ▼ +Construct email body from: + - FAILED_REMOTES[] + - $(pwd) ← repository path + - REFS[] ← updated refs + - $MIRROR_LOG ← log file path + │ + ▼ +Send via: mail -s "[git-mirror] Push failure in <reponame>" "$MIRROR_NOTIFY" +``` + +Three gates must all pass before an email is sent: +1. At least one remote must have failed +2. `MIRROR_NOTIFY` must be set to a non-empty value +3. The `mail` command must be present on the system + +--- + +## Email Notification Implementation + +### Trigger Conditions + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_NOTIFY:-}" ]]; then +``` + +This compound condition checks: + +| Expression | Test | +|------------|------| +| `${#FAILED_REMOTES[@]} -gt 0` | The `FAILED_REMOTES` array has at least one element | +| `-n "${MIRROR_NOTIFY:-}"` | The `MIRROR_NOTIFY` variable is non-empty | + +The `${MIRROR_NOTIFY:-}` expansion with `:-` is critical under `set -u` — it prevents an "unbound variable" error if `MIRROR_NOTIFY` was never set. The `:-` substitutes an empty string for an unset variable, and then `-n` tests whether that string is non-empty. + +The `&&` short-circuit operator means the `MIRROR_NOTIFY` check is only evaluated if there are failures. This is functionally irrelevant (both must pass), but reads naturally: "if there are failures AND notifications are configured." + +### Prerequisite Check + +```bash +if command -v mail &>/dev/null; then +``` + +Before attempting to send email, the script checks if the `mail` command exists: + +| Component | Purpose | +|-----------|---------| +| `command -v mail` | Looks up `mail` in PATH; prints its path if found, exits non-zero if not | +| `&>/dev/null` | Suppresses both stdout and stderr (we only care about the exit code) | + +This is the POSIX-compliant way to check for command availability, preferred over: +- `which mail` — not POSIX, may behave differently across systems +- `type mail` — bash-specific, prints extra output +- `hash mail` — bash-specific + +If `mail` is not found, the entire notification block is skipped silently — no error, no warning. This allows deploying the hook on systems without `mail` configured. + +### Email Body Construction + +```bash +{ + echo "Mirror push failed for the following remotes:" + printf ' - %s\n' "${FAILED_REMOTES[@]}" + echo "" + echo "Repository: $(pwd)" + echo "Refs updated:" + printf ' %s\n' "${REFS[@]}" + echo "" + echo "Check log: $MIRROR_LOG" +} | mail -s "[git-mirror] Push failure in $(basename "$(pwd)")" "$MIRROR_NOTIFY" +``` + +The `{ ... }` command group constructs the email body as a multi-line string. This group acts as a single compound command whose combined stdout is piped to `mail`. + +**`printf ' - %s\n' "${FAILED_REMOTES[@]}"`** — Iterates over each element of the `FAILED_REMOTES` array, printing each as a bulleted list item. Using `printf` with an array is a bash idiom: the format string is applied to each argument in turn. + +For example, if `FAILED_REMOTES=(sourceforge codeberg)`, the output is: +``` + - sourceforge + - codeberg +``` + +**`$(pwd)`** — Expands to the current working directory. In a bare repository hook, this is the bare repository path (e.g., `/srv/git/project-tick.git`). + +**`printf ' %s\n' "${REFS[@]}"`** — Lists all refs that were updated in this push, providing context about what triggered the mirror. + +**`$MIRROR_LOG`** — Points the reader to the log file for detailed push output and error messages. + +### Subject Line Format + +```bash +mail -s "[git-mirror] Push failure in $(basename "$(pwd)")" "$MIRROR_NOTIFY" +``` + +The subject line follows the pattern: + +``` +[git-mirror] Push failure in <repository-directory-name> +``` + +**`$(basename "$(pwd)")`** — Extracts just the directory name from the full path: +- Input: `/srv/git/project-tick.git` +- Output: `project-tick.git` + +The `[git-mirror]` prefix allows email filters to route or prioritize these notifications: + +``` +# Example email filter rule +Subject contains "[git-mirror]" → Move to "Git Alerts" folder +``` + +### Recipient Configuration + +The recipient is specified by the `MIRROR_NOTIFY` environment variable, passed as the final argument to `mail`: + +```bash +mail -s "subject" "$MIRROR_NOTIFY" +``` + +The variable is quoted (`"$MIRROR_NOTIFY"`) to handle email addresses that might contain special characters (though standard email addresses typically don't). + +--- + +## The MIRROR_NOTIFY Variable + +### Enabling Notifications + +Set the variable in the environment of the process running the git daemon: + +```bash +# In systemd service file +Environment=MIRROR_NOTIFY=admin@project-tick.org + +# In shell profile +export MIRROR_NOTIFY=admin@project-tick.org + +# In a wrapper script +MIRROR_NOTIFY=admin@project-tick.org exec hooks/post-receive +``` + +### Disabling Notifications + +Notifications are disabled by default. To explicitly disable: + +```bash +# Unset the variable +unset MIRROR_NOTIFY + +# Or set to empty +export MIRROR_NOTIFY="" +``` + +### Multiple Recipients + +The `mail` command typically supports multiple recipients as a comma-separated list: + +```bash +export MIRROR_NOTIFY="admin@project-tick.org,ops@project-tick.org" +``` + +Or as space-separated arguments (behavior depends on the MTA): + +```bash +export MIRROR_NOTIFY="admin@project-tick.org ops@project-tick.org" +``` + +For reliable multi-recipient support, modify the script to loop over recipients: + +```bash +for addr in $MIRROR_NOTIFY; do + { ... } | mail -s "subject" "$addr" +done +``` + +--- + +## Email Body Format + +### Complete Email Example + +``` +From: git@server.project-tick.org +To: admin@project-tick.org +Subject: [git-mirror] Push failure in project-tick.git + +Mirror push failed for the following remotes: + - sourceforge + - codeberg + +Repository: /srv/git/project-tick.git +Refs updated: + refs/heads/main + refs/tags/v2.1.0 + +Check log: /var/log/git-mirror.log +``` + +### Field-by-Field Breakdown + +| Field | Source | Example | +|-------|--------|---------| +| Failed remotes list | `"${FAILED_REMOTES[@]}"` | `sourceforge`, `codeberg` | +| Repository path | `$(pwd)` | `/srv/git/project-tick.git` | +| Updated refs | `"${REFS[@]}"` | `refs/heads/main`, `refs/tags/v2.1.0` | +| Log file path | `$MIRROR_LOG` | `/var/log/git-mirror.log` | +| Subject repo name | `$(basename "$(pwd)")` | `project-tick.git` | + +The email body provides enough context for an administrator to: +1. Identify which mirrors are out of sync (failed remotes) +2. Locate the repository to investigate (repository path) +3. Understand what changed (updated refs) +4. Access detailed error output (log file path) + +--- + +## Mail Command Integration + +### The mail Command + +The hook uses the `mail` command (also known as `mailx`), a standard Unix mail user agent. It reads the message body from stdin and sends it to the specified recipient via the system's mail transfer agent (MTA). + +```bash +echo "body" | mail -s "subject" recipient@example.com +``` + +### Installing mail on Different Systems + +| System | Package | Command | +|--------|---------|---------| +| Debian/Ubuntu | `sudo apt install mailutils` | `mail` | +| RHEL/CentOS | `sudo yum install mailx` | `mail` | +| Fedora | `sudo dnf install mailx` | `mail` | +| Arch Linux | `sudo pacman -S s-nail` | `mail` | +| Alpine | `apk add mailx` | `mail` | +| NixOS | `nix-env -iA nixpkgs.mailutils` | `mail` | +| macOS | Pre-installed (or `brew install mailutils`) | `mail` | + +### Mail Transfer Agent Configuration + +The `mail` command hands off the message to a local MTA. Common MTAs include: + +| MTA | Package | Use Case | +|-----|---------|----------| +| Postfix | `postfix` | Full-featured, most common | +| Exim | `exim4` | Flexible, Debian default | +| msmtp | `msmtp` | Lightweight relay to external SMTP | +| ssmtp | `ssmtp` | Minimal relay (deprecated) | +| OpenSMTPD | `opensmtpd` | Simple, secure | + +For a server that only needs to send outbound email (no receiving), `msmtp` is the simplest option: + +```bash +# /etc/msmtprc +account default +host smtp.example.com +port 587 +auth on +user notifications@project-tick.org +password APP_PASSWORD +tls on +from git-mirror@project-tick.org +``` + +### Testing Email Delivery + +```bash +# Test basic mail delivery +echo "Test message from git-mirror" | mail -s "Test" admin@project-tick.org + +# Check mail queue +mailq + +# Check mail log +sudo tail /var/log/mail.log +``` + +--- + +## Failure Scenarios and Edge Cases + +| Scenario | Behavior | User Impact | +|----------|----------|-------------| +| `MIRROR_NOTIFY` not set | Notification block skipped entirely | None | +| `MIRROR_NOTIFY` set to empty string | `-n` test fails; notification skipped | None | +| `mail` command not found | `command -v mail` fails; notification skipped | None | +| MTA not configured | `mail` command may succeed but message is undeliverable | Email queued or bounced locally | +| MTA fails to send | `mail` exits non-zero; under `set -e`... | See note below | +| Invalid email address | MTA accepts the message but it bounces later | Bounce email to local mailbox | +| All remotes succeed | `${#FAILED_REMOTES[@]} -gt 0` is false; notification skipped | None — no false alerts | +| REFS array is empty | `printf` prints nothing for refs section | Email sent with empty refs list | + +**Note on `set -e` and `mail` failure**: The `mail` command is inside an `if` block (the `if command -v mail` block), which shields it from `set -e`. However, if `mail` itself fails, the pipeline `{ ... } | mail ...` would fail. Under `pipefail`, this could cause the `if` block's body to fail. In practice, `mail` commands rarely fail immediately — they queue messages locally even if delivery fails. + +--- + +## Extending the Notification System + +### Adding Webhook Notifications + +To send notifications via a generic webhook (e.g., for monitoring tools): + +```bash +# Add after the mail block: +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_WEBHOOK:-}" ]]; then + if command -v curl &>/dev/null; then + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d "{ + \"event\": \"mirror_failure\", + \"repository\": \"$(basename "$(pwd)")\", + \"failed_remotes\": [$(printf '\"%s\",' "${FAILED_REMOTES[@]}" | sed 's/,$//')] + }" \ + "$MIRROR_WEBHOOK" 2>/dev/null || true + fi +fi +``` + +Configure with: `export MIRROR_WEBHOOK="https://monitoring.example.com/hooks/git-mirror"` + +### Adding Slack Integration + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_SLACK_WEBHOOK:-}" ]]; then + if command -v curl &>/dev/null; then + REMOTE_LIST=$(printf '• %s\\n' "${FAILED_REMOTES[@]}") + REF_LIST=$(printf '• %s\\n' "${REFS[@]}") + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d "{ + \"text\": \":x: *Mirror push failed* in \`$(basename "$(pwd)")\`\", + \"blocks\": [ + { + \"type\": \"section\", + \"text\": { + \"type\": \"mrkdwn\", + \"text\": \":x: *Mirror push failed* in \`$(basename "$(pwd)")\`\\n\\n*Failed remotes:*\\n${REMOTE_LIST}\\n\\n*Refs updated:*\\n${REF_LIST}\" + } + } + ] + }" \ + "$MIRROR_SLACK_WEBHOOK" 2>/dev/null || true + fi +fi +``` + +Configure with: `export MIRROR_SLACK_WEBHOOK="https://hooks.slack.com/services/T.../B.../xxx"` + +### Adding Discord Integration + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_DISCORD_WEBHOOK:-}" ]]; then + if command -v curl &>/dev/null; then + REMOTE_LIST=$(printf '- %s\n' "${FAILED_REMOTES[@]}") + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d "{ + \"content\": \"**Mirror push failed** in \`$(basename "$(pwd)")\`\\n\\nFailed remotes:\\n${REMOTE_LIST}\\n\\nCheck log: ${MIRROR_LOG}\" + }" \ + "$MIRROR_DISCORD_WEBHOOK" 2>/dev/null || true + fi +fi +``` + +Configure with: `export MIRROR_DISCORD_WEBHOOK="https://discord.com/api/webhooks/xxx/yyy"` + +### Adding Matrix Integration + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_MATRIX_WEBHOOK:-}" ]]; then + if command -v curl &>/dev/null; then + REMOTE_LIST=$(printf '- %s\n' "${FAILED_REMOTES[@]}") + MSG="Mirror push failed in $(basename "$(pwd)")\n\nFailed remotes:\n${REMOTE_LIST}" + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d "{ + \"msgtype\": \"m.text\", + \"body\": \"${MSG}\" + }" \ + "$MIRROR_MATRIX_WEBHOOK" 2>/dev/null || true + fi +fi +``` + +### Adding SMS Notifications + +Using Twilio as an example: + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_TWILIO_SID:-}" ]]; then + if command -v curl &>/dev/null; then + REMOTE_LIST=$(printf '%s, ' "${FAILED_REMOTES[@]}" | sed 's/, $//') + curl -sf -X POST \ + "https://api.twilio.com/2010-04-01/Accounts/${MIRROR_TWILIO_SID}/Messages.json" \ + -u "${MIRROR_TWILIO_SID}:${MIRROR_TWILIO_TOKEN}" \ + -d "From=${MIRROR_TWILIO_FROM}" \ + -d "To=${MIRROR_TWILIO_TO}" \ + -d "Body=Mirror push failed in $(basename "$(pwd)"). Failed: ${REMOTE_LIST}" \ + 2>/dev/null || true + fi +fi +``` + +--- + +## Notification Suppression + +To temporarily suppress notifications without removing the `MIRROR_NOTIFY` configuration: + +```bash +# Method 1: Unset for a single invocation +unset MIRROR_NOTIFY +echo "..." | hooks/post-receive + +# Method 2: Override with empty string +MIRROR_NOTIFY="" hooks/post-receive + +# Method 3: Remove notification config from systemd +# Edit the service file and remove the MIRROR_NOTIFY line +sudo systemctl edit git-daemon +``` + +The hook's design ensures notifications are never sent unless explicitly enabled, so the default state is already "suppressed." + +--- + +## Monitoring and Alerting Integration + +For production deployments, the notification system can be integrated with monitoring platforms: + +### Prometheus + Alertmanager + +Expose mirror status as a Prometheus metric by writing to a textfile collector: + +```bash +# Add to the end of the hook: +METRICS_DIR="/var/lib/prometheus/node-exporter" +if [[ -d "$METRICS_DIR" ]]; then + cat > "$METRICS_DIR/git_mirror.prom" <<EOF +# HELP git_mirror_last_run_timestamp_seconds Unix timestamp of the last mirror run +# TYPE git_mirror_last_run_timestamp_seconds gauge +git_mirror_last_run_timestamp_seconds $(date +%s) +# HELP git_mirror_failed_remotes_total Number of remotes that failed in the last run +# TYPE git_mirror_failed_remotes_total gauge +git_mirror_failed_remotes_total ${#FAILED_REMOTES[@]} +# HELP git_mirror_succeeded_remotes_total Number of remotes that succeeded +# TYPE git_mirror_succeeded_remotes_total gauge +git_mirror_succeeded_remotes_total ${#SUCCEEDED_REMOTES[@]} +EOF +fi +``` + +### Healthcheck Pings + +Integrate with uptime monitoring services: + +```bash +# Ping a healthcheck endpoint on success +if [[ ${#FAILED_REMOTES[@]} -eq 0 && -n "${MIRROR_HEALTHCHECK_URL:-}" ]]; then + curl -sf "$MIRROR_HEALTHCHECK_URL" 2>/dev/null || true +fi + +# Signal failure +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_HEALTHCHECK_URL:-}" ]]; then + curl -sf "${MIRROR_HEALTHCHECK_URL}/fail" 2>/dev/null || true +fi +``` + +Configure with: `export MIRROR_HEALTHCHECK_URL="https://hc-ping.com/uuid-here"` + +This allows dead-man's-switch monitoring — if no push occurs within the expected interval, the monitoring service alerts. diff --git a/docs/handbook/hooks/overview.md b/docs/handbook/hooks/overview.md new file mode 100644 index 0000000000..510d0a68b6 --- /dev/null +++ b/docs/handbook/hooks/overview.md @@ -0,0 +1,712 @@ +# Hooks — Overview + +## Table of Contents + +- [Introduction](#introduction) +- [What Are Git Hooks?](#what-are-git-hooks) +- [Hook Types in Git](#hook-types-in-git) + - [Client-Side Hooks](#client-side-hooks) + - [Server-Side Hooks](#server-side-hooks) +- [Project-Tick Hook Architecture](#project-tick-hook-architecture) +- [The post-receive Hook](#the-post-receive-hook) + - [Purpose and Design Goals](#purpose-and-design-goals) + - [Script Anatomy](#script-anatomy) + - [Configuration Block](#configuration-block) + - [Remote Auto-Detection](#remote-auto-detection) + - [Logging Subsystem](#logging-subsystem) + - [Main Execution Loop](#main-execution-loop) + - [Push Strategy](#push-strategy) + - [Result Tracking](#result-tracking) + - [Failure Notification](#failure-notification) + - [Exit Behavior](#exit-behavior) +- [Supported Forge Targets](#supported-forge-targets) + - [GitHub](#github) + - [GitLab](#gitlab) + - [Codeberg](#codeberg) + - [SourceForge](#sourceforge) +- [Authentication Methods](#authentication-methods) + - [SSH Key Authentication](#ssh-key-authentication) + - [HTTPS Token Authentication](#https-token-authentication) +- [Environment Variables](#environment-variables) +- [Installation Guide](#installation-guide) +- [Directory Layout](#directory-layout) +- [Operational Flow Diagram](#operational-flow-diagram) +- [Interaction with Other Project-Tick Components](#interaction-with-other-project-tick-components) +- [Troubleshooting Common Issues](#troubleshooting-common-issues) +- [Security Considerations](#security-considerations) +- [Related Documentation](#related-documentation) + +--- + +## Introduction + +The `hooks/` directory in the Project-Tick monorepo contains Git hook scripts that automate repository management tasks. These hooks are designed to run on the bare repository that serves as the canonical upstream source for the Project-Tick project. + +The hooks system currently consists of a single, well-structured script: + +| File | Type | Purpose | +|------|------|---------| +| `hooks/post-receive` | Bash script | Mirror pushes to multiple forge platforms | + +This document provides a comprehensive explanation of how the hooks system works, how Git hooks function in general, and how the Project-Tick hook integrates with the broader project infrastructure. + +--- + +## What Are Git Hooks? + +Git hooks are executable scripts that Git runs automatically at specific points in the version control workflow. They reside in the `.git/hooks/` directory of a repository (or the `hooks/` directory of a bare repository). Git ships with sample hook scripts (with `.sample` extensions) that are inactive by default. + +Hooks serve as extension points for automating tasks such as: + +- Enforcing commit message conventions +- Running linters or tests before accepting commits +- Triggering CI/CD pipelines after pushes +- Synchronizing mirrors to external platforms +- Sending notifications on repository events + +A hook is activated by placing an executable file with the correct name (no extension) in the hooks directory. Git invokes the hook at the corresponding event and passes relevant data via standard input or command-line arguments. + +--- + +## Hook Types in Git + +### Client-Side Hooks + +Client-side hooks run on the developer's local machine during operations like committing, merging, and rebasing: + +| Hook | Trigger | Use Case | +|------|---------|----------| +| `pre-commit` | Before a commit is created | Lint source files, check formatting | +| `prepare-commit-msg` | After default message generated | Auto-populate commit templates | +| `commit-msg` | After user enters message | Validate commit message format | +| `post-commit` | After commit completes | Post-commit notifications | +| `pre-rebase` | Before rebase starts | Prevent rebasing published branches | +| `post-merge` | After a merge completes | Restore tracked file permissions | +| `pre-push` | Before push to remote | Run tests before sharing code | +| `post-checkout` | After `git checkout` | Set up working directory environment | + +### Server-Side Hooks + +Server-side hooks run on the repository that receives pushes. These are the hooks relevant to the Project-Tick infrastructure: + +| Hook | Trigger | Input | Use Case | +|------|---------|-------|----------| +| `pre-receive` | Before any refs updated | `<old-sha> <new-sha> <refname>` per line on stdin | Reject pushes that violate policies | +| `update` | Per-ref, before each ref updated | `<refname> <old-sha> <new-sha>` as arguments | Per-branch access control | +| `post-receive` | After all refs updated | `<old-sha> <new-sha> <refname>` per line on stdin | Trigger CI, mirrors, notifications | +| `post-update` | After refs updated | Updated ref names as arguments | Update `info/refs` for dumb HTTP | + +The **post-receive** hook is the one used by Project-Tick. It fires after all refs have been successfully updated, making it the ideal place for mirror synchronization — the push to the canonical repo has already succeeded, so mirroring can proceed without blocking the original pusher. + +--- + +## Project-Tick Hook Architecture + +The Project-Tick hooks system follows a minimal, single-script architecture: + +``` +hooks/ +└── post-receive # The only hook script — handles multi-forge mirroring +``` + +The script is stored in the monorepo source tree at `hooks/post-receive` and is deployed to the bare repository at the path: + +``` +/path/to/project-tick.git/hooks/post-receive +``` + +### Design Principles + +1. **Single responsibility** — The script does exactly one thing: mirror pushes to configured forge remotes. +2. **Fail-safe defaults** — If no mirror remotes are configured, the script exits silently without error. +3. **Comprehensive logging** — Every action is logged with UTC timestamps. +4. **Non-blocking on partial failure** — If one remote fails, the script continues pushing to the remaining remotes. +5. **Notification support** — Optional email alerts on failure. +6. **Zero external dependencies** — Uses only bash builtins, `git`, `date`, `tee`, and optionally `mail`. + +--- + +## The post-receive Hook + +### Purpose and Design Goals + +The `post-receive` script in `hooks/post-receive` serves as a multi-forge mirror synchronization tool. When a push lands on the canonical bare repository, this hook automatically replicates all refs (branches, tags, notes) to every configured mirror remote. + +The opening comment block documents this purpose: + +```bash +# ============================================================================== +# post-receive hook — Mirror push to multiple forges +# ============================================================================== +``` + +### Script Anatomy + +The script is structured into four clearly delineated sections: + +1. **Header block** (lines 1–33) — Shebang, documentation comments, and usage instructions +2. **Configuration block** (lines 35–53) — Variable initialization and remote auto-detection +3. **Logging function** (lines 55–62) — The `log()` helper +4. **Main execution** (lines 64–112) — Ref reading, push loop, summary, notification, exit + +The script begins with strict error handling: + +```bash +#!/usr/bin/env bash +set -euo pipefail +``` + +The `set -euo pipefail` line enables three safety nets: + +| Flag | Effect | +|------|--------| +| `-e` | Exit immediately if any command fails | +| `-u` | Treat unset variables as errors | +| `-o pipefail` | A pipeline fails if any component fails, not just the last command | + +### Configuration Block + +The configuration block initializes three environment-controlled variables: + +```bash +MIRROR_REMOTES="${MIRROR_REMOTES:-}" +MIRROR_LOG="${MIRROR_LOG:-/var/log/git-mirror.log}" +``` + +| Variable | Default | Purpose | +|----------|---------|---------| +| `MIRROR_REMOTES` | `""` (empty — triggers auto-detection) | Space-separated list of git remote names | +| `MIRROR_LOG` | `/var/log/git-mirror.log` | Path to the log file | +| `MIRROR_NOTIFY` | `""` (unset — notifications disabled) | Email address for failure alerts | + +The `${VAR:-default}` syntax provides defaults while allowing environment variable overrides. This means an administrator can control behavior without modifying the script: + +```bash +MIRROR_REMOTES="github gitlab" MIRROR_LOG=/tmp/mirror.log ./hooks/post-receive +``` + +### Remote Auto-Detection + +If `MIRROR_REMOTES` is empty (the default), the script auto-detects mirror targets: + +```bash +if [[ -z "$MIRROR_REMOTES" ]]; then + MIRROR_REMOTES=$(git remote | grep -v '^origin$' || true) +fi +``` + +This runs `git remote` to list all configured remotes, then filters out `origin` with `grep -v '^origin$'`. The `|| true` suffix prevents `set -e` from terminating the script if `grep` finds no matches (which would produce exit code 1). + +The rationale: `origin` typically points to the canonical repository itself. Everything else is assumed to be a mirror target. This convention allows adding new mirrors simply by running: + +```bash +git remote add <name> <url> +``` + +If after auto-detection the list is still empty, the script exits cleanly: + +```bash +if [[ -z "$MIRROR_REMOTES" ]]; then + echo "[mirror] No mirror remotes configured. Skipping." >&2 + exit 0 +fi +``` + +This is a **non-error exit** (`exit 0`) because having no mirrors is a valid configuration — the hook should not cause the push to appear to have failed. + +### Logging Subsystem + +The `log()` function provides timestamped logging to both stdout and a persistent log file: + +```bash +log() { + local timestamp + timestamp="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$timestamp] $*" +} +``` + +Key characteristics: + +- **UTC timestamps** — `date -u` ensures consistent timestamps regardless of server timezone. +- **Format** — `[2026-04-05 14:30:00 UTC] message` — ISO 8601 date with human-readable time. +- **Dual output** — `tee -a` appends to `$MIRROR_LOG` while also writing to stdout. +- **Graceful fallback** — If the log file is not writable (permissions, missing directory), `2>/dev/null` suppresses the `tee` error, and the `||` fallback ensures the message still appears on stdout. +- **`local` variable** — The `timestamp` variable is scoped to the function to avoid polluting the global namespace. + +### Main Execution Loop + +The main section begins by reading the ref update data from stdin: + +```bash +log "=== Mirror push triggered ===" + +REFS=() +while read -r oldrev newrev refname; do + REFS+=("$refname") + log " ref: $refname ($oldrev -> $newrev)" +done +``` + +Git's `post-receive` hook receives one line per updated ref on stdin, formatted as: + +``` +<old-sha1> <new-sha1> <refname> +``` + +For example: +``` +abc123 def456 refs/heads/main +000000 789abc refs/tags/v1.0.0 +``` + +The `read -r` flag prevents backslash interpretation. Each ref name is accumulated in the `REFS` bash array for later use in notifications. + +### Push Strategy + +For each mirror remote, the script performs a `--mirror --force` push: + +```bash +for remote in $MIRROR_REMOTES; do + log "Pushing to remote: $remote" + + if git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null; then + SUCCEEDED_REMOTES+=("$remote") + log " ✓ Successfully pushed to $remote" + else + FAILED_REMOTES+=("$remote") + log " ✗ FAILED to push to $remote" + fi +done +``` + +The `git push` flags are critical: + +| Flag | Effect | +|------|--------| +| `--mirror` | Push all refs under `refs/` — branches, tags, notes, replace refs, everything. Also deletes remote refs that no longer exist locally. | +| `--force` | Force-update refs that have diverged. Ensures the mirror is an exact copy. | + +The `2>&1` redirects stderr to stdout so both success and error messages are captured by `tee`. The `if` statement checks the exit code of the entire pipeline — if `git push` fails (non-zero exit), the remote is added to `FAILED_REMOTES`. + +**Important**: The loop does **not** use `set -e` behavior for individual pushes because the `if` statement captures the exit code rather than triggering an immediate exit. This ensures all remotes are attempted even if some fail. + +### Result Tracking + +Two arrays track the outcome: + +```bash +FAILED_REMOTES=() +SUCCEEDED_REMOTES=() +``` + +After the loop, a summary is logged: + +```bash +log "--- Summary ---" +log " Succeeded: ${SUCCEEDED_REMOTES[*]:-none}" +log " Failed: ${FAILED_REMOTES[*]:-none}" +``` + +The `${array[*]:-none}` syntax expands all array elements separated by spaces, or prints "none" if the array is empty. + +### Failure Notification + +When mirrors fail and `MIRROR_NOTIFY` is set, the script sends an email: + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_NOTIFY:-}" ]]; then + if command -v mail &>/dev/null; then + { + echo "Mirror push failed for the following remotes:" + printf ' - %s\n' "${FAILED_REMOTES[@]}" + echo "" + echo "Repository: $(pwd)" + echo "Refs updated:" + printf ' %s\n' "${REFS[@]}" + echo "" + echo "Check log: $MIRROR_LOG" + } | mail -s "[git-mirror] Push failure in $(basename "$(pwd)")" "$MIRROR_NOTIFY" + fi +fi +``` + +The notification includes: + +- Which remotes failed (`FAILED_REMOTES`) +- The repository path (`$(pwd)`) +- Which refs were updated (`REFS`) +- Where to find detailed logs (`$MIRROR_LOG`) + +The subject line uses the repository directory name: `[git-mirror] Push failure in project-tick.git`. + +The `command -v mail &>/dev/null` check ensures the script doesn't crash if `mail` is not installed — it simply skips notification silently. + +### Exit Behavior + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 ]]; then + log "=== Finished with errors ===" + exit 1 +fi + +log "=== Finished successfully ===" +exit 0 +``` + +| Condition | Exit Code | Meaning | +|-----------|-----------|---------| +| All remotes succeeded | `0` | Success — the pusher sees no error | +| One or more remotes failed | `1` | Failure — the pusher sees an error message | +| No remotes configured | `0` | No-op — silent success | + +**Note**: A non-zero exit from `post-receive` does **not** reject the push (the refs are already updated). It only causes Git to display the hook's output as an error to the pusher. This alerts the developer that mirroring failed without rolling back their work. + +--- + +## Supported Forge Targets + +The script header documents four forge platforms with example remote URLs: + +### GitHub + +```bash +# SSH +git remote add github git@github.com:Project-Tick/Project-Tick.git + +# HTTPS with token +git remote add github https://x-access-token:TOKEN@github.com/Project-Tick/Project-Tick.git +``` + +GitHub uses `x-access-token` as the username for personal access tokens and GitHub App installation tokens. + +### GitLab + +```bash +# SSH +git remote add gitlab git@gitlab.com:Project-Tick/Project-Tick.git + +# HTTPS with token +git remote add gitlab https://oauth2:TOKEN@gitlab.com/Project-Tick/Project-Tick.git +``` + +GitLab uses `oauth2` as the username for personal access tokens with HTTPS. + +### Codeberg + +```bash +# SSH +git remote add codeberg git@codeberg.org:Project-Tick/Project-Tick.git + +# HTTPS with token +git remote add codeberg https://TOKEN@codeberg.org/Project-Tick/Project-Tick.git +``` + +Codeberg (Gitea-based) accepts the token directly as the username with no password. + +### SourceForge + +```bash +# SSH only +git remote add sourceforge ssh://USERNAME@git.code.sf.net/p/project-tick/code +``` + +SourceForge uses a non-standard SSH URL format with a username prefix and a project-specific path structure. + +--- + +## Authentication Methods + +### SSH Key Authentication + +SSH-based authentication requires: + +1. An SSH keypair accessible to the user running the Git daemon +2. The public key registered on each forge platform +3. Correct SSH host key verification (or entries in `~/.ssh/known_hosts`) + +For automated server-side usage, a dedicated deploy key is recommended: + +```bash +# Generate a dedicated mirror key +ssh-keygen -t ed25519 -f ~/.ssh/mirror_key -N "" + +# Configure SSH to use it for each host +cat >> ~/.ssh/config <<EOF +Host github.com + IdentityFile ~/.ssh/mirror_key +Host gitlab.com + IdentityFile ~/.ssh/mirror_key +Host codeberg.org + IdentityFile ~/.ssh/mirror_key +EOF +``` + +### HTTPS Token Authentication + +HTTPS authentication embeds the token in the remote URL. The token format varies by forge: + +| Forge | URL Format | Token Type | +|-------|------------|------------| +| GitHub | `https://x-access-token:TOKEN@github.com/...` | Personal Access Token or App Installation Token | +| GitLab | `https://oauth2:TOKEN@gitlab.com/...` | Personal Access Token | +| Codeberg | `https://TOKEN@codeberg.org/...` | Application Token | + +**Security warning**: Tokens embedded in remote URLs are stored in the Git config file of the bare repository. Ensure the repository directory has restrictive permissions (`chmod 700`). + +--- + +## Environment Variables + +The script supports three environment variables for runtime configuration: + +### `MIRROR_REMOTES` + +```bash +MIRROR_REMOTES="${MIRROR_REMOTES:-}" +``` + +- **Type**: Space-separated string of git remote names +- **Default**: Empty (triggers auto-detection of all non-`origin` remotes) +- **Example**: `MIRROR_REMOTES="github gitlab codeberg"` +- **Use case**: Restrict mirroring to specific remotes, e.g., push to GitHub and GitLab but skip Codeberg temporarily + +### `MIRROR_LOG` + +```bash +MIRROR_LOG="${MIRROR_LOG:-/var/log/git-mirror.log}" +``` + +- **Type**: Filesystem path +- **Default**: `/var/log/git-mirror.log` +- **Example**: `MIRROR_LOG=/var/log/project-tick/mirror.log` +- **Requirements**: The directory must exist and be writable by the user running the hook. If not writable, the script falls back to stdout-only logging. + +### `MIRROR_NOTIFY` + +```bash +"${MIRROR_NOTIFY:-}" +``` + +- **Type**: Email address string +- **Default**: Empty (notifications disabled) +- **Example**: `MIRROR_NOTIFY=admin@project-tick.org` +- **Requirements**: The `mail` command must be available on the system. If `mail` is not installed, the notification is silently skipped. + +--- + +## Installation Guide + +### Step 1: Locate the Bare Repository + +```bash +# The bare repository is typically at: +cd /srv/git/project-tick.git +# or +cd /var/lib/gitolite/repositories/project-tick.git +``` + +### Step 2: Copy the Hook Script + +```bash +cp /path/to/Project-Tick/hooks/post-receive hooks/post-receive +chmod +x hooks/post-receive +``` + +### Step 3: Configure Mirror Remotes + +```bash +git remote add github git@github.com:Project-Tick/Project-Tick.git +git remote add gitlab git@gitlab.com:Project-Tick/Project-Tick.git +git remote add codeberg git@codeberg.org:Project-Tick/Project-Tick.git +``` + +### Step 4: Verify Remote Configuration + +```bash +git remote -v +# github git@github.com:Project-Tick/Project-Tick.git (push) +# gitlab git@gitlab.com:Project-Tick/Project-Tick.git (push) +# codeberg git@codeberg.org:Project-Tick/Project-Tick.git (push) +# origin (local bare repo — no push URL) +``` + +### Step 5: Set Up Logging + +```bash +sudo mkdir -p /var/log/ +sudo touch /var/log/git-mirror.log +sudo chown git:git /var/log/git-mirror.log +``` + +### Step 6: (Optional) Configure Notifications + +```bash +# Set in the shell environment of the user running the git daemon +export MIRROR_NOTIFY="admin@project-tick.org" +``` + +### Step 7: Test the Hook + +```bash +echo "0000000000000000000000000000000000000000 $(git rev-parse HEAD) refs/heads/main" | hooks/post-receive +``` + +--- + +## Directory Layout + +``` +Project-Tick/ +├── hooks/ +│ └── post-receive # The mirror hook script (source copy) +│ +├── docs/handbook/hooks/ +│ ├── overview.md # This document +│ ├── post-receive-hook.md # Deep-dive into the post-receive script +│ ├── mirror-configuration.md # Mirror setup and forge configuration +│ ├── logging-system.md # Logging internals +│ └── notification-system.md # Failure notification system +│ +└── /path/to/project-tick.git/ # Deployed bare repository + └── hooks/ + └── post-receive # Deployed copy (executable) +``` + +--- + +## Operational Flow Diagram + +``` +Developer pushes to canonical repo + │ + ▼ + Git updates refs in bare repo + │ + ▼ + post-receive hook is invoked + │ + ▼ + Read stdin: old-sha, new-sha, refname + │ + ▼ + Auto-detect mirror remotes + (all remotes except "origin") + │ + ├── No remotes? ──► exit 0 (silent) + │ + ▼ + For each remote: + git push --mirror --force $remote + │ + ├── Success ──► add to SUCCEEDED_REMOTES + │ + └── Failure ──► add to FAILED_REMOTES + │ + ▼ + MIRROR_NOTIFY set? + │ + ├── Yes + mail available ──► send email + │ + └── No ──► skip + │ + ▼ + Log summary + │ + ├── Any failures? ──► exit 1 + │ + └── All ok? ──► exit 0 +``` + +--- + +## Interaction with Other Project-Tick Components + +### cgit Integration + +The Project-Tick monorepo includes `cgit/` — a web frontend for Git repositories. The `post-receive` mirroring hook complements cgit by ensuring that the repositories displayed on the cgit web interface are kept in sync across multiple forges. + +The `cgit/contrib/hooks/post-receive.agefile` hook (a separate, cgit-specific hook) updates the `info/web/last-modified` file for cgit's cache invalidation. In a multi-hook setup, both hooks can be combined using a wrapper script. + +### lefthook Integration + +The `lefthook.yml` at the repository root configures client-side hooks for the development workflow. This is complementary to the server-side `post-receive` hook — lefthook manages pre-commit and pre-push checks locally, while `post-receive` manages post-push mirroring on the server. + +### CI Pipeline + +The `ci/` directory contains CI configuration. The mirror hook runs independently of CI — it triggers on the bare repository while CI typically triggers on the forge platforms that receive the mirrored pushes. + +--- + +## Troubleshooting Common Issues + +### Hook Not Executing + +```bash +# Check permissions +ls -la hooks/post-receive +# Must show: -rwxr-xr-x or similar with execute bit + +# Fix permissions +chmod +x hooks/post-receive +``` + +### "No mirror remotes configured" + +```bash +# Verify remotes exist +git remote -v + +# If empty, add remotes: +git remote add github git@github.com:Project-Tick/Project-Tick.git +``` + +### SSH Authentication Failures + +```bash +# Test SSH connectivity +ssh -T git@github.com +ssh -T git@gitlab.com +ssh -T git@codeberg.org + +# Check SSH agent +ssh-add -l +``` + +### Log File Not Writable + +```bash +# Check permissions +ls -la /var/log/git-mirror.log + +# Create with correct ownership +sudo touch /var/log/git-mirror.log +sudo chown $(whoami) /var/log/git-mirror.log +``` + +### Push Rejected by Remote + +```bash +# Check if the remote repository exists +# Check if the token/key has push permissions +# Check if branch protection rules block --force pushes +``` + +--- + +## Security Considerations + +1. **Token storage** — HTTPS tokens embedded in remote URLs are stored in plain text in the git config. Restrict access to the bare repository directory. +2. **SSH keys** — Use dedicated deploy keys with minimal permissions (push-only, no admin). +3. **Log file contents** — The log file may contain ref names and remote names but should not contain credentials. However, restrict access to logs as ref names may be sensitive. +4. **`set -euo pipefail`** — The strict bash mode prevents silent failures and unset variable references that could lead to unexpected behavior. +5. **`--force` flag** — The `--force` flag overwrites remote refs unconditionally. This is intentional for mirroring but means the canonical repo must be protected against unauthorized pushes. + +--- + +## Related Documentation + +- [post-receive-hook.md](post-receive-hook.md) — Line-by-line analysis of the post-receive script +- [mirror-configuration.md](mirror-configuration.md) — Detailed mirror remote setup guide +- [logging-system.md](logging-system.md) — Logging system internals +- [notification-system.md](notification-system.md) — Email notification system diff --git a/docs/handbook/hooks/post-receive-hook.md b/docs/handbook/hooks/post-receive-hook.md new file mode 100644 index 0000000000..845291d7f2 --- /dev/null +++ b/docs/handbook/hooks/post-receive-hook.md @@ -0,0 +1,778 @@ +# post-receive Hook — Deep Analysis + +## Table of Contents + +- [Introduction](#introduction) +- [File Location and Deployment](#file-location-and-deployment) +- [Complete Source Listing](#complete-source-listing) +- [Line-by-Line Analysis](#line-by-line-analysis) + - [Line 1: Shebang](#line-1-shebang) + - [Lines 2–33: Documentation Header](#lines-2-33-documentation-header) + - [Line 35: Strict Mode](#line-35-strict-mode) + - [Lines 41–42: Variable Initialization](#lines-41-42-variable-initialization) + - [Lines 45–47: Remote Auto-Detection](#lines-45-47-remote-auto-detection) + - [Lines 49–52: Empty Remote Guard](#lines-49-52-empty-remote-guard) + - [Lines 57–61: The log() Function](#lines-57-61-the-log-function) + - [Line 67: Trigger Banner](#line-67-trigger-banner) + - [Lines 70–74: Stdin Ref Reading Loop](#lines-70-74-stdin-ref-reading-loop) + - [Lines 76–77: Result Arrays](#lines-76-77-result-arrays) + - [Lines 79–90: Mirror Push Loop](#lines-79-90-mirror-push-loop) + - [Lines 92–94: Summary Logging](#lines-92-94-summary-logging) + - [Lines 97–109: Failure Notification](#lines-97-109-failure-notification) + - [Lines 112–116: Exit Logic](#lines-112-116-exit-logic) +- [Data Flow Analysis](#data-flow-analysis) + - [Input Data](#input-data) + - [Internal State](#internal-state) + - [Output Channels](#output-channels) +- [Bash Constructs Reference](#bash-constructs-reference) +- [Error Handling Strategy](#error-handling-strategy) +- [Pipeline Behavior Under pipefail](#pipeline-behavior-under-pipefail) +- [Race Conditions and Concurrency](#race-conditions-and-concurrency) +- [Performance Characteristics](#performance-characteristics) +- [Testing the Hook](#testing-the-hook) + - [Manual Invocation](#manual-invocation) + - [Dry Run Approach](#dry-run-approach) + - [Unit Testing with Mocks](#unit-testing-with-mocks) +- [Modification Guide](#modification-guide) + - [Adding a New Remote Type](#adding-a-new-remote-type) + - [Adding Retry Logic](#adding-retry-logic) + - [Adding Webhook Notifications](#adding-webhook-notifications) + - [Selective Ref Mirroring](#selective-ref-mirroring) +- [Comparison with Alternative Approaches](#comparison-with-alternative-approaches) + +--- + +## Introduction + +The `post-receive` hook at `hooks/post-receive` is the single operational hook in the Project-Tick hooks system. It implements multi-forge mirror synchronization — whenever a push lands on the canonical bare repository, this script replicates all refs to every configured mirror remote. + +This document provides an exhaustive, line-by-line analysis of the script, covering every variable, control structure, and design decision. + +--- + +## File Location and Deployment + +**Source location** (in the monorepo): +``` +Project-Tick/hooks/post-receive +``` + +**Deployed location** (in the bare repository): +``` +/path/to/project-tick.git/hooks/post-receive +``` + +**File type**: Bash shell script +**Permissions required**: Executable (`chmod +x`) +**Interpreter**: `/usr/bin/env bash` (portable shebang) +**Total lines**: 116 + +--- + +## Complete Source Listing + +For reference, the complete script with line numbers: + +```bash + 1 #!/usr/bin/env bash + 2 # ============================================================================== + 3 # post-receive hook — Mirror push to multiple forges + 4 # ============================================================================== + 5 # + 6 # Place this file in your bare repository: + 7 # /path/to/project-tick.git/hooks/post-receive + 8 # + 9 # Make it executable: + 10 # chmod +x hooks/post-receive + 11 # + 12 # Configuration: + 13 # Set mirror remotes in the bare repo: + 14 # + 15 # git remote add github git@github.com:Project-Tick/Project-Tick.git + 16 # git remote add gitlab git@gitlab.com:Project-Tick/Project-Tick.git + 17 # git remote add codeberg git@codeberg.org:Project-Tick/Project-Tick.git + 18 # git remote add sourceforge ssh://USERNAME@git.code.sf.net/p/project-tick/code + 19 # + 20 # Or use HTTPS with token auth: + 21 # + 22 # git remote add github https://x-access-token:TOKEN@github.com/Project-Tick/Project-Tick.git + 23 # git remote add gitlab https://oauth2:TOKEN@gitlab.com/Project-Tick/Project-Tick.git + 24 # git remote add codeberg https://TOKEN@codeberg.org/Project-Tick/Project-Tick.git + 25 # + 26 # Environment variables (optional): + 27 # MIRROR_REMOTES — space-separated list of remote names to push to. + 28 # Defaults to all configured mirror remotes. + 29 # MIRROR_LOG — path to log file. Defaults to /var/log/git-mirror.log + 30 # MIRROR_NOTIFY — email address for failure notifications (requires mail cmd) + 31 # + 32 # ============================================================================== + 33 + 34 set -euo pipefail + 35 + 36 # --------------------- + 37 # Configuration + 38 # --------------------- + 39 + 40 MIRROR_REMOTES="${MIRROR_REMOTES:-}" + 41 MIRROR_LOG="${MIRROR_LOG:-/var/log/git-mirror.log}" + 42 + 43 if [[ -z "$MIRROR_REMOTES" ]]; then + 44 MIRROR_REMOTES=$(git remote | grep -v '^origin$' || true) + 45 fi + 46 + 47 if [[ -z "$MIRROR_REMOTES" ]]; then + 48 echo "[mirror] No mirror remotes configured. Skipping." >&2 + 49 exit 0 + 50 fi + 51 + 52 # --------------------- + 53 # Logging + 54 # --------------------- + 55 log() { + 56 local timestamp + 57 timestamp="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + 58 echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$timestamp] $*" + 59 } + 60 + 61 # --------------------- + 62 # Main + 63 # --------------------- + 64 + 65 log "=== Mirror push triggered ===" + 66 + 67 REFS=() + 68 while read -r oldrev newrev refname; do + 69 REFS+=("$refname") + 70 log " ref: $refname ($oldrev -> $newrev)" + 71 done + 72 + 73 FAILED_REMOTES=() + 74 SUCCEEDED_REMOTES=() + 75 + 76 for remote in $MIRROR_REMOTES; do + 77 log "Pushing to remote: $remote" + 78 + 79 if git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null; then + 80 SUCCEEDED_REMOTES+=("$remote") + 81 log " ✓ Successfully pushed to $remote" + 82 else + 83 FAILED_REMOTES+=("$remote") + 84 log " ✗ FAILED to push to $remote" + 85 fi + 86 done + 87 + 88 log "--- Summary ---" + 89 log " Succeeded: ${SUCCEEDED_REMOTES[*]:-none}" + 90 log " Failed: ${FAILED_REMOTES[*]:-none}" + 91 + 92 if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_NOTIFY:-}" ]]; then + 93 if command -v mail &>/dev/null; then + 94 { + 95 echo "Mirror push failed for the following remotes:" + 96 printf ' - %s\n' "${FAILED_REMOTES[@]}" + 97 echo "" + 98 echo "Repository: $(pwd)" + 99 echo "Refs updated:" +100 printf ' %s\n' "${REFS[@]}" +101 echo "" +102 echo "Check log: $MIRROR_LOG" +103 } | mail -s "[git-mirror] Push failure in $(basename "$(pwd)")" "$MIRROR_NOTIFY" +104 fi +105 fi +106 +107 if [[ ${#FAILED_REMOTES[@]} -gt 0 ]]; then +108 log "=== Finished with errors ===" +109 exit 1 +110 fi +111 +112 log "=== Finished successfully ===" +113 exit 0 +``` + +--- + +## Line-by-Line Analysis + +### Line 1: Shebang + +```bash +#!/usr/bin/env bash +``` + +The `#!/usr/bin/env bash` shebang is the portable way to invoke bash. Instead of hardcoding `/bin/bash` (which varies across systems — on NixOS, for example, bash is at `/run/current-system/sw/bin/bash`), `env` searches `$PATH` for the `bash` binary. + +**Why bash specifically?** The script uses bash-specific features: +- Arrays (`REFS=()`, `REFS+=()`) +- `[[ ]]` conditional expressions +- `${array[*]:-default}` expansion +- `${#array[@]}` array length +- `set -o pipefail` + +These are not available in POSIX `sh`. + +### Lines 2–33: Documentation Header + +The header block is an extensive comment documenting: + +1. **What the hook does** — "Mirror push to multiple forges" +2. **Where to deploy it** — `/path/to/project-tick.git/hooks/post-receive` +3. **How to make it executable** — `chmod +x hooks/post-receive` +4. **How to configure mirror remotes** — four SSH examples plus three HTTPS examples +5. **Environment variables** — `MIRROR_REMOTES`, `MIRROR_LOG`, `MIRROR_NOTIFY` + +This self-documenting style means an administrator can understand the hook without reading external documentation. + +### Line 35: Strict Mode + +```bash +set -euo pipefail +``` + +This is bash "strict mode," composed of three flags: + +**`-e` (errexit)**: If any command returns a non-zero exit code, the script terminates immediately. Exceptions: +- Commands in `if` conditions +- Commands followed by `&&` or `||` +- Commands in `while`/`until` conditions + +This is why the `git push` is wrapped in `if` — to capture its exit code without triggering `errexit`. + +**`-u` (nounset)**: Referencing an unset variable causes an immediate error instead of silently expanding to an empty string. This catches typos like `$MIIROR_LOG`. The `${VAR:-default}` syntax is used throughout to safely reference variables that may not be set. + +**`-o pipefail`**: By default, a pipeline's exit code is the exit code of the last command. With `pipefail`, the pipeline's exit code is the exit code of the rightmost command that failed (non-zero). This matters for: + +```bash +git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" +``` + +Without `pipefail`, this pipeline would succeed as long as `tee` succeeds, even if `git push` fails. With `pipefail`, a `git push` failure propagates through the pipeline. However, note the `2>/dev/null` after `tee` which may affect this — see the [Pipeline Behavior Under pipefail](#pipeline-behavior-under-pipefail) section. + +### Lines 41–42: Variable Initialization + +```bash +MIRROR_REMOTES="${MIRROR_REMOTES:-}" +MIRROR_LOG="${MIRROR_LOG:-/var/log/git-mirror.log}" +``` + +The `${VAR:-default}` expansion works as follows: + +| `VAR` state | Expansion | +|-------------|-----------| +| Set to value | The value | +| Set to empty string | The default | +| Unset | The default | + +For `MIRROR_REMOTES`, the default is an empty string, which triggers auto-detection later. For `MIRROR_LOG`, the default is `/var/log/git-mirror.log`. + +Note that `MIRROR_NOTIFY` is **not** initialized here — it's referenced later with `${MIRROR_NOTIFY:-}` inline. This is safe because the `:-` syntax prevents `set -u` from triggering on an unset variable. + +### Lines 45–47: Remote Auto-Detection + +```bash +if [[ -z "$MIRROR_REMOTES" ]]; then + MIRROR_REMOTES=$(git remote | grep -v '^origin$' || true) +fi +``` + +**`git remote`** — Lists all remote names, one per line. In the bare repository, this might output: + +``` +origin +github +gitlab +codeberg +sourceforge +``` + +**`grep -v '^origin$'`** — Inverts the match, removing lines that are exactly `origin`. The `^` and `$` anchors prevent matching remotes like `origin-backup` or `my-origin`. + +**`|| true`** — If `grep` finds no matches (all remotes are `origin`, or there are no remotes at all), it exits with code 1. Under `set -e`, this would terminate the script. The `|| true` ensures the command always succeeds. + +**`$(...)`** — Command substitution captures the output. Multi-line output from `git remote` is collapsed into a space-separated string when assigned to a scalar variable, which is exactly what the `for remote in $MIRROR_REMOTES` loop expects. + +### Lines 49–52: Empty Remote Guard + +```bash +if [[ -z "$MIRROR_REMOTES" ]]; then + echo "[mirror] No mirror remotes configured. Skipping." >&2 + exit 0 +fi +``` + +If auto-detection produced no results (no non-origin remotes), the script prints a message to stderr (`>&2`) and exits with code 0. Using stderr ensures the message doesn't interfere with any stdout processing, while exit code 0 ensures the push appears successful to the user. + +### Lines 57–61: The log() Function + +```bash +log() { + local timestamp + timestamp="$(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo "[$timestamp] $*" | tee -a "$MIRROR_LOG" 2>/dev/null || echo "[$timestamp] $*" +} +``` + +Detailed breakdown: + +1. **`local timestamp`** — Declares `timestamp` as function-local. Without `local`, it would be a global variable that persists after the function returns. + +2. **`date -u '+%Y-%m-%d %H:%M:%S UTC'`** — Generates a UTC timestamp. The `-u` flag is critical for server environments where multiple time zones may be in play. The format string produces output like `2026-04-05 14:30:00 UTC`. + +3. **`echo "[$timestamp] $*"`** — `$*` expands all function arguments as a single string. Unlike `$@`, which preserves argument boundaries, `$*` joins them with the first character of `$IFS` (default: space). For logging, this distinction doesn't matter. + +4. **`| tee -a "$MIRROR_LOG"`** — `tee -a` appends (`-a`) to the log file while passing through to stdout. This achieves dual output — the message appears in the hook's stdout (visible to the pusher) and is persisted in the log file. + +5. **`2>/dev/null`** — Suppresses `tee`'s stderr. If `$MIRROR_LOG` doesn't exist or isn't writable, `tee` would print an error like `tee: /var/log/git-mirror.log: Permission denied`. Suppressing this keeps the output clean. + +6. **`|| echo "[$timestamp] $*"`** — If the entire `echo | tee` pipeline fails (e.g., the log file is unwritable and `tee` exits non-zero under `pipefail`), this fallback ensures the message still reaches stdout. + +### Line 67: Trigger Banner + +```bash +log "=== Mirror push triggered ===" +``` + +A visual separator in the log that marks the start of a new mirror operation. The `===` delimiters make it easy to grep for session boundaries: + +```bash +grep "=== Mirror push" /var/log/git-mirror.log +``` + +### Lines 70–74: Stdin Ref Reading Loop + +```bash +REFS=() +while read -r oldrev newrev refname; do + REFS+=("$refname") + log " ref: $refname ($oldrev -> $newrev)" +done +``` + +**`REFS=()`** — Initializes an empty bash array to accumulate ref names. + +**`read -r oldrev newrev refname`** — Reads one line from stdin, splitting on whitespace into three variables. The `-r` flag prevents backslash interpretation (e.g., `\n` is read literally, not as a newline). + +Git feeds post-receive hooks with lines formatted as: +``` +<40-char old SHA-1> <40-char new SHA-1> <refname> +``` + +The `refname` variable captures everything after the second space, which is correct because ref names don't contain spaces. + +**Special SHA values**: + +| Old SHA | New SHA | Meaning | +|---------|---------|---------| +| `0000...0000` | `abc123...` | New ref created (branch/tag created) | +| `abc123...` | `def456...` | Ref updated (normal push) | +| `abc123...` | `0000...0000` | Ref deleted (branch/tag deleted) | + +**`REFS+=("$refname")`** — Appends the ref name to the array. The quotes around `$refname` are important to preserve the value as a single array element. + +### Lines 76–77: Result Arrays + +```bash +FAILED_REMOTES=() +SUCCEEDED_REMOTES=() +``` + +Two arrays that accumulate results as the push loop iterates. These are used later for the summary log and the notification email. + +### Lines 79–90: Mirror Push Loop + +```bash +for remote in $MIRROR_REMOTES; do + log "Pushing to remote: $remote" + + if git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null; then + SUCCEEDED_REMOTES+=("$remote") + log " ✓ Successfully pushed to $remote" + else + FAILED_REMOTES+=("$remote") + log " ✗ FAILED to push to $remote" + fi +done +``` + +**`for remote in $MIRROR_REMOTES`** — Note the unquoted `$MIRROR_REMOTES`. This is intentional — word splitting on spaces produces individual remote names. If it were quoted as `"$MIRROR_REMOTES"`, the entire string would be treated as a single remote name. + +**`git push --mirror --force "$remote"`**: +- `--mirror` — Push all refs under `refs/` to the remote, and delete remote refs that don't exist locally. This includes `refs/heads/*`, `refs/tags/*`, `refs/notes/*`, `refs/replace/*`, etc. +- `--force` — Force-update diverged refs. Without this, pushes to refs that have been rewritten (e.g., after a force-push to the canonical repo) would be rejected. +- `"$remote"` — Quoted to handle remote names with unusual characters (defensive coding). + +**`2>&1`** — Merges stderr into stdout. Git's push progress and error messages go to stderr by default; this redirect ensures they're all captured by `tee`. + +**`| tee -a "$MIRROR_LOG" 2>/dev/null`** — Appends the complete push output to the log file. The `2>/dev/null` suppresses errors from `tee` if the log isn't writable. + +**`if ... then ... else`** — The `if` statement tests the exit code of the pipeline. Under `pipefail`, the pipeline fails if `git push` fails (regardless of `tee`'s exit code). + +### Lines 92–94: Summary Logging + +```bash +log "--- Summary ---" +log " Succeeded: ${SUCCEEDED_REMOTES[*]:-none}" +log " Failed: ${FAILED_REMOTES[*]:-none}" +``` + +**`${SUCCEEDED_REMOTES[*]:-none}`** — Expands the array elements separated by spaces. If the array is empty, the `:-none` default kicks in and prints "none". This produces output like: + +``` +[2026-04-05 14:30:05 UTC] --- Summary --- +[2026-04-05 14:30:05 UTC] Succeeded: github gitlab codeberg +[2026-04-05 14:30:05 UTC] Failed: none +``` + +### Lines 97–109: Failure Notification + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_NOTIFY:-}" ]]; then + if command -v mail &>/dev/null; then + { + echo "Mirror push failed for the following remotes:" + printf ' - %s\n' "${FAILED_REMOTES[@]}" + echo "" + echo "Repository: $(pwd)" + echo "Refs updated:" + printf ' %s\n' "${REFS[@]}" + echo "" + echo "Check log: $MIRROR_LOG" + } | mail -s "[git-mirror] Push failure in $(basename "$(pwd)")" "$MIRROR_NOTIFY" + fi +fi +``` + +**`${#FAILED_REMOTES[@]}`** — Array length operator. Returns the number of elements in `FAILED_REMOTES`. + +**`-gt 0`** — "Greater than 0" — at least one remote failed. + +**`-n "${MIRROR_NOTIFY:-}"`** — Tests if `MIRROR_NOTIFY` is non-empty. The `:-` prevents `set -u` from triggering on an unset variable. + +**`command -v mail &>/dev/null`** — Checks if `mail` is available. `command -v` is the POSIX-compliant way to check for command existence (preferred over `which`). + +**`{ ... } | mail ...`** — A command group constructs the email body as a multi-line string, piping it to `mail`: +- `printf ' - %s\n' "${FAILED_REMOTES[@]}"` — Prints each failed remote as a bulleted list item +- `$(pwd)` — The bare repository path +- `printf ' %s\n' "${REFS[@]}"` — Lists all refs that were updated +- `$MIRROR_LOG` — Points to the log file for detailed output + +**`mail -s "..." "$MIRROR_NOTIFY"`** — Sends an email with the given subject line to the configured address. + +### Lines 112–116: Exit Logic + +```bash +if [[ ${#FAILED_REMOTES[@]} -gt 0 ]]; then + log "=== Finished with errors ===" + exit 1 +fi + +log "=== Finished successfully ===" +exit 0 +``` + +The exit code is meaningful but not catastrophic: + +- **`exit 1`** — Git displays the hook's output to the pusher with a warning that the hook failed. The push itself has already succeeded (refs were already updated before `post-receive` ran). +- **`exit 0`** — Clean completion, no warning displayed. + +--- + +## Data Flow Analysis + +### Input Data + +``` +┌──────────────────────────────────────────────────────────┐ +│ stdin │ +│ <old-sha> <new-sha> refs/heads/main │ +│ <old-sha> <new-sha> refs/tags/v1.0.0 │ +│ ... │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ + while read -r oldrev newrev refname + │ + ├──► REFS[] array (refname values) + └──► log output (old→new transitions) +``` + +### Internal State + +``` +┌─────────────────────────────────────────┐ +│ MIRROR_REMOTES "github gitlab ..." │ +│ MIRROR_LOG "/var/log/..." │ +│ MIRROR_NOTIFY "admin@..." or "" │ +│ REFS[] ref names from push │ +│ FAILED_REMOTES[] failed remote names │ +│ SUCCEEDED_REMOTES[] ok remote names │ +└─────────────────────────────────────────┘ +``` + +### Output Channels + +| Channel | Target | Content | +|---------|--------|---------| +| stdout | Pusher's terminal | Log messages, push output | +| `$MIRROR_LOG` | Log file on disk | All log messages + push output | +| `mail` | Email recipient | Failure notification body | +| Exit code | Git server | 0 (success) or 1 (failure) | + +--- + +## Bash Constructs Reference + +| Construct | Line(s) | Meaning | +|-----------|---------|---------| +| `${VAR:-default}` | 40–41 | Use `default` if `VAR` is unset or empty | +| `${VAR:-}` | 92 | Expand to empty string if unset (avoids `set -u` error) | +| `$(command)` | 44, 57, 98, 103 | Command substitution | +| `[[ -z "$VAR" ]]` | 43, 47 | Test if string is empty | +| `[[ -n "$VAR" ]]` | 92 | Test if string is non-empty | +| `${#ARRAY[@]}` | 92, 107 | Array length | +| `${ARRAY[*]:-x}` | 89, 90 | All elements or default | +| `ARRAY+=("item")` | 69, 80, 83 | Append to array | +| `read -r a b c` | 68 | Read space-delimited fields | +| `cmd 2>&1` | 79 | Redirect stderr to stdout | +| `cmd &>/dev/null` | 93 | Redirect all output to null | +| `\|\| true` | 44 | Force success exit code | +| `local var` | 56 | Function-scoped variable | +| `{ ... }` | 94–102 | Command group for I/O redirection | + +--- + +## Error Handling Strategy + +The script uses a layered error handling approach: + +1. **Global strict mode** (`set -euo pipefail`) catches programming errors +2. **`if` wrappers** protect commands that are expected to fail (git push) +3. **`|| true` guards** prevent `set -e` from triggering on grep no-match +4. **`2>/dev/null` + `||` fallback** in `log()` handles unwritable log files +5. **`command -v` checks** prevent crashes when optional tools are missing +6. **`${VAR:-}` expansions** prevent `set -u` errors on optional variables + +This means the script will: +- ✓ Continue if one mirror push fails (handled by `if`) +- ✓ Continue if the log file is unwritable (handled by `2>/dev/null || echo`) +- ✓ Continue if `mail` is not installed (handled by `command -v` check) +- ✓ Continue if no remotes are configured (handled by `exit 0` guard) +- ✗ Abort on undefined variables (caught by `set -u`) +- ✗ Abort on unexpected command failures (caught by `set -e`) + +--- + +## Pipeline Behavior Under pipefail + +The push pipeline deserves special attention: + +```bash +git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null +``` + +Under `pipefail`, the pipeline's exit code is determined by the rightmost failing command: + +| `git push` exit | `tee` exit | Pipeline exit | +|-----------------|------------|---------------| +| 0 (success) | 0 (success) | 0 | +| 128 (failure) | 0 (success) | 128 | +| 0 (success) | 1 (failure) | 1 | +| 128 (failure) | 1 (failure) | 128 | + +If both fail, the rightmost failure wins — but in practice, `tee` rarely fails because its stderr is redirected to `/dev/null`, and even if it can't write to the log file, it still passes data through to stdout (which always works). + +However, there's a subtlety: `tee`'s `2>/dev/null` only suppresses `tee`'s own error messages. If `tee` can't open the log file for writing, it will still exit with a non-zero code, which could mask a `git push` success under `pipefail`. In practice, this is unlikely to cause problems because `tee` typically succeeds even if it can't write (it still outputs to stdout). + +--- + +## Race Conditions and Concurrency + +If multiple pushes arrive simultaneously, multiple instances of `post-receive` may run concurrently. Potential issues: + +1. **Log file interleaving** — Multiple `tee -a` writes to the same log file. The `-a` (append) mode is file-system atomic for writes smaller than `PIPE_BUF` (typically 4096 bytes), so individual log lines won't corrupt each other, but they may interleave. + +2. **Simultaneous mirror pushes** — Two hooks pushing to the same mirror remote concurrently. Git handles this gracefully — one push will complete first, and the second will either fast-forward or be a no-op. + +3. **REFS array** — Each hook instance has its own `REFS` array (separate bash process), so there's no cross-instance contamination. + +--- + +## Performance Characteristics + +| Operation | Typical Duration | Notes | +|-----------|-----------------|-------| +| Remote auto-detection | <10 ms | `git remote` + `grep` on local config | +| Stdin reading | <1 ms | Reading a few lines from pipe | +| `git push --mirror` per remote | 1–60 seconds | Network-bound; depends on delta size | +| Logging | <1 ms per call | Local file I/O | +| Email notification | 100–500 ms | Depends on MTA | + +Total execution time is dominated by the mirror push loop. With 4 remotes, worst case is ~4 minutes for large pushes. The pushes are **sequential**, not parallel — see [Modification Guide](#modification-guide) for adding parallelism. + +--- + +## Testing the Hook + +### Manual Invocation + +Simulate a push by feeding ref data on stdin: + +```bash +cd /path/to/project-tick.git +echo "0000000000000000000000000000000000000000 $(git rev-parse HEAD) refs/heads/main" \ + | hooks/post-receive +``` + +### Dry Run Approach + +Create a modified version that uses `echo` instead of `git push`: + +```bash +# In the hook, temporarily replace: +# git push --mirror --force "$remote" +# With: +# echo "[DRY RUN] Would push --mirror --force to $remote" +``` + +### Unit Testing with Mocks + +```bash +#!/usr/bin/env bash +# test-post-receive.sh — Test the hook with mock remotes + +# Create a temporary bare repo +TMPDIR=$(mktemp -d) +git init --bare "$TMPDIR/test.git" +cd "$TMPDIR/test.git" + +# Add a mock remote (pointing to a local bare repo) +git init --bare "$TMPDIR/mirror.git" +git remote add testmirror "$TMPDIR/mirror.git" + +# Copy the hook +cp /path/to/hooks/post-receive hooks/post-receive +chmod +x hooks/post-receive + +# Create a dummy ref +git hash-object -t commit --stdin <<< "tree $(git hash-object -t tree /dev/null) +author Test <test@test> 0 +0000 +committer Test <test@test> 0 +0000 + +test" > /dev/null + +# Invoke the hook +echo "0000000000000000000000000000000000000000 $(git rev-parse HEAD 2>/dev/null || echo abc123) refs/heads/main" \ + | MIRROR_LOG="$TMPDIR/mirror.log" hooks/post-receive + +echo "Exit code: $?" +cat "$TMPDIR/mirror.log" + +# Cleanup +rm -rf "$TMPDIR" +``` + +--- + +## Modification Guide + +### Adding a New Remote Type + +Simply add a new git remote to the bare repository. No script modification needed: + +```bash +cd /path/to/project-tick.git +git remote add bitbucket git@bitbucket.org:Project-Tick/Project-Tick.git +``` + +The auto-detection mechanism will pick it up automatically on the next push. + +### Adding Retry Logic + +To add retry logic for transient network failures, replace the push section: + +```bash +for remote in $MIRROR_REMOTES; do + log "Pushing to remote: $remote" + + MAX_RETRIES=3 + RETRY_DELAY=5 + attempt=0 + push_success=false + + while [[ $attempt -lt $MAX_RETRIES ]]; do + attempt=$((attempt + 1)) + log " Attempt $attempt/$MAX_RETRIES for $remote" + + if git push --mirror --force "$remote" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null; then + push_success=true + break + fi + + if [[ $attempt -lt $MAX_RETRIES ]]; then + log " Retrying in ${RETRY_DELAY}s..." + sleep "$RETRY_DELAY" + fi + done + + if $push_success; then + SUCCEEDED_REMOTES+=("$remote") + log " ✓ Successfully pushed to $remote" + else + FAILED_REMOTES+=("$remote") + log " ✗ FAILED to push to $remote after $MAX_RETRIES attempts" + fi +done +``` + +### Adding Webhook Notifications + +To add webhook notifications (e.g., Slack, Discord) alongside email: + +```bash +# After the mail block, add: +if [[ ${#FAILED_REMOTES[@]} -gt 0 && -n "${MIRROR_WEBHOOK:-}" ]]; then + if command -v curl &>/dev/null; then + PAYLOAD=$(cat <<EOF +{ + "text": "Mirror push failed in $(basename "$(pwd)")", + "remotes": "$(printf '%s, ' "${FAILED_REMOTES[@]}")", + "refs": "$(printf '%s, ' "${REFS[@]}")" +} +EOF +) + curl -s -X POST -H "Content-Type: application/json" \ + -d "$PAYLOAD" "$MIRROR_WEBHOOK" 2>/dev/null || true + fi +fi +``` + +### Selective Ref Mirroring + +To mirror only specific branches instead of using `--mirror`: + +```bash +for remote in $MIRROR_REMOTES; do + for ref in "${REFS[@]}"; do + log "Pushing $ref to $remote" + if git push --force "$remote" "$ref" 2>&1 | tee -a "$MIRROR_LOG" 2>/dev/null; then + log " ✓ $ref -> $remote" + else + log " ✗ FAILED $ref -> $remote" + FAILED_REMOTES+=("$remote:$ref") + fi + done +done +``` + +--- + +## Comparison with Alternative Approaches + +| Approach | Pros | Cons | +|----------|------|------| +| **post-receive hook** (current) | Simple, self-contained, zero external deps | Sequential pushes, coupled to git server | +| **CI-triggered mirror** | Parallel, retries built-in, monitoring | Requires CI infrastructure, higher latency | +| **Cron-based sync** | Decoupled from push flow | Delayed mirroring, may miss rapid pushes | +| **Git federation** | Native, protocol-level | Not widely supported | +| **Grokmirror** | Efficient for large repos | Complex setup, Python dependency | + +The post-receive hook approach chosen by Project-Tick is the simplest and most appropriate for a single-repository setup where immediate mirroring is desired. |
