summaryrefslogtreecommitdiff
path: root/docs/handbook/hooks/post-receive-hook.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/handbook/hooks/post-receive-hook.md')
-rw-r--r--docs/handbook/hooks/post-receive-hook.md778
1 files changed, 778 insertions, 0 deletions
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.