Adds a Makefile for standardizing test/lint/install workflows, a GitHub Actions CI workflow, and coverage reporting tools. Also updates install.sh to support version detection.
Signed-off-by: Shane Jaroch <chown_tee@proton.me>
--- /dev/null
+---
+name: CI
+
+"on":
+ push:
+ workflow_dispatch:
+ inputs:
+ debug:
+ description: 'Enable debug logging (GCRYPT_DEBUG=1)'
+ required: false
+ type: boolean
+ default: false
+ schedule:
+ - cron: "0 0 * * 0" # Sunday at 12 AM
+
+jobs:
+ # Handles Ubuntu and macOS
+ install-unix:
+ runs-on: ${{ matrix.os }}
+
+ env:
+ GCRYPT_DEBUG: ${{ inputs.debug && '1' || '' }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Dependencies (Linux)
+ if: runner.os == 'Linux'
+ run: sudo apt-get update && sudo apt-get install -y git python3-docutils
+
+ - name: Dependencies (macOS)
+ if: runner.os == 'macOS'
+ run: brew install coreutils python3 git docutils
+
+ - name: Help [make]
+ run: make
+
+ - name: Test Installer
+ run: bash ./tests/test-install-logic.sh
+
+ - name: Install [make install]
+ run: sudo make install
+
+ - name: Verify [make check/install]
+ run: make check/install
+
+
+ # Handles RedHat (UBI Container)
+ install-rh:
+ runs-on: ubuntu-latest
+
+ env:
+ GCRYPT_DEBUG: ${{ inputs.debug && '1' || '' }}
+
+ container:
+ image: registry.access.redhat.com/ubi9/ubi:latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ # dnf is slow in containers. We cache the dnf cache directory.
+ - name: Cache DNF
+ uses: actions/cache@v4
+ with:
+ path: /var/cache/dnf
+ key: ${{ runner.os }}-ubi9-dnf-v1
+ restore-keys: |
+ ${{ runner.os }}-ubi9-dnf-
+
+ - name: Dependencies [redhat]
+ run: dnf install -y git python3-docutils make man-db
+
+ - name: Help [make]
+ run: make
+
+ - name: Test Installer
+ run: bash ./tests/test-install-logic.sh
+
+ - name: Install [make install]
+ run: make install # container runs as sudo
+
+ - name: Verify [make check/install]
+ run: make check/install
+
+
+ # Lint job (no-op currently)
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install ShellCheck
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+
+ - name: Lint [make lint]
+ continue-on-error: true
+ run: make lint
/debian/files
/debian/git-remote-gcrypt.substvars
/debian/git-remote-gcrypt
+
+# Test coverage
+.coverage/
+!.coverage/**
+# scratch pad
+.tmp/
+!.tmp/**
+
--- /dev/null
+SHELL:=/bin/bash
+# .ONESHELL:
+# .EXPORT_ALL_VARIABLES:
+.DEFAULT_GOAL := _help
+.SHELLFLAGS = -ec
+
+.PHONY: _help
+_help:
+ @printf "\nUsage: make <command>, valid commands:\n\n"
+ @awk 'BEGIN {FS = ":.*?##H "}; \
+ /##H/ && !/@awk.*?##H/ { \
+ target=$$1; doc=$$2; \
+ if (length(target) > max) max = length(target); \
+ targets[NR] = target; docs[NR] = doc; list[NR] = 1; \
+ } \
+ END { \
+ for (i = 1; i <= NR; i++) { \
+ if (list[i]) printf " \033[1;34m%-*s\033[0m %s\n", max, targets[i], docs[i]; \
+ } \
+ print ""; \
+ }' $(MAKEFILE_LIST)
+
+.PHONY: _all
+_all: lint test/installer test/system
+ @$(call print_success,All checks passed!)
+
+.PHONY: vars
+vars: ##H Debug: Print project variables
+ @$(foreach v,$(sort $(.VARIABLES)), \
+ $(if $(filter file command line override,$(origin $(v))), \
+ $(info $(v) = $($(v))) \
+ ) \
+ )
+
+define print_err
+ printf "\033[1;31m%s\033[0m\n" "$(1)"
+endef
+
+define print_warn
+ printf "\033[1;33m%s\033[0m\n" "$(1)"
+endef
+
+define print_success
+ printf "\033[1;34m✓ %s\033[0m\n" "$(1)"
+endef
+
+define print_info
+ printf "\033[1;36m%s\033[0m\n" "$(1)"
+endef
+
+
+.PHONY: check/deps
+check/deps: ##H Verify kcov & shellcheck
+ @command -v shellcheck >/dev/null 2>&1 || { $(call print_err,Error: 'shellcheck' not installed.); exit 1; }
+ @$(call print_info, --- shellcheck version ---) && shellcheck --version
+ @command -v kcov >/dev/null 2>&1 || { $(call print_err,Error: 'kcov' not installed.); exit 1; }
+ @$(call print_info, --- kcov version ---) && kcov --version
+ @$(call print_success,Dependencies OK.)
+
+
+.PHONY: lint
+lint: ##H Run shellcheck
+ # lint install script
+ shellcheck install.sh
+ @$(call print_success,OK.)
+ # lint system/binary script
+ shellcheck git-remote-gcrypt
+ @$(call print_success,OK.)
+ # lint test scripts
+ shellcheck tests/*.sh
+ @$(call print_success,OK.)
+
+# --- Test Config ---
+PWD := $(shell pwd)
+COV_ROOT := $(PWD)/.coverage
+COV_SYSTEM := $(COV_ROOT)/system
+COV_INSTALL := $(COV_ROOT)/installer
+
+.PHONY: test/, test
+test/: test
+test: test/installer test/system test/cov ##H All tests & coverage
+
+.PHONY: test/installer
+test/installer: check/deps ##H Test installer logic
+ @rm -rf $(COV_INSTALL)
+ @mkdir -p $(COV_INSTALL)
+ @export COV_DIR=$(COV_INSTALL); \
+ kcov --bash-handle-sh-invocation \
+ --include-pattern=install.sh \
+ --exclude-path=$(PWD)/.git,$(PWD)/tests \
+ $(COV_INSTALL) \
+ ./tests/test-install-logic.sh
+
+
+.PHONY: test/system
+test/system: check/deps ##H Test system functionality
+ @rm -rf $(COV_SYSTEM)
+ @mkdir -p $(COV_SYSTEM)
+ @export GPG_TTY=$$(tty); \
+ [ -n "$(DEBUG)$(V)" ] && export GCRYPT_DEBUG=1 && print_warn "Debug mode enabled"; \
+ export GIT_CONFIG_PARAMETERS="'gcrypt.gpg-args=--pinentry-mode loopback --no-tty'"; \
+ export COV_DIR=$(COV_SYSTEM); \
+ for test_script in tests/system-test*.sh; do \
+ kcov --include-path=$(PWD) \
+ --include-pattern=git-remote-gcrypt \
+ --exclude-path=$(PWD)/.git,$(PWD)/tests \
+ $(COV_SYSTEM) \
+ ./$$test_script || true; \
+ done
+
+
+define CHECK_COVERAGE
+@XML_FILE=$$(find $(1) -name "cobertura.xml" 2>/dev/null | grep "merged" | head -n 1); \
+[ -z "$$XML_FILE" ] && XML_FILE=$$(find $(1) -name "cobertura.xml" 2>/dev/null | head -n 1); \
+if [ -f "$$XML_FILE" ]; then \
+ echo ""; \
+ echo "Report for: file://$$(dirname "$$XML_FILE")/index.html"; \
+ XML_FILE="$$XML_FILE" PATT="$(2)" python3 tests/coverage_report.py; \
+ fi
+endef
+
+.PHONY: test/cov
+test/cov: ##H Show coverage gaps
+ $(call CHECK_COVERAGE,$(COV_SYSTEM),git-remote-gcrypt)
+ $(call CHECK_COVERAGE,$(COV_INSTALL),install.sh)
+
+
+
+.PHONY: install/, install
+install/: install
+install: ##H Install system-wide
+ @$(call print_target,install)
+ @$(call print_info,Installing git-remote-gcrypt...)
+ @bash ./install.sh
+ @$(call print_success,Installed.)
+
+.PHONY: install/user
+install/user: ##H make install prefix=~/.local
+ $(MAKE) install prefix=~/.local
+
+.PHONY: check/install
+check/install: ##H Verify installation works
+ bash ./tests/verify-system-install.sh
+
+.PHONY: uninstall/, uninstall
+uninstall/: uninstall
+uninstall: ##H Uninstall
+ @$(call print_target,uninstall)
+ @bash ./uninstall.sh
+ @$(call print_success,Uninstalled.)
+
+.PHONY: uninstall/user
+uninstall/user: ##H make uninstall prefix=~/.local
+ $(MAKE) uninstall prefix=~/.local
+
+
+
+.PHONY: clean
+clean: ##H Clean up
+ rm -rf .coverage
set -f # noglob
set -C # noclobber
+VERSION="@@DEV_VERSION@@"
+if [ "${1:-}" = "-v" ] || [ "${1:-}" = "--version" ]; then
+ echo "git-remote-gcrypt version $VERSION" >&2
+ exit 0
+fi
+
export GITCEPTION="${GITCEPTION:-}+" # Reuse $Gref except when stacked
Gref="refs/gcrypt/gitception$GITCEPTION"
Gref_rbranch="refs/heads/master"
{
local status_=
exec 4>&1 &&
- status_=$(rungpg --status-fd 3 -q -d 3>&1 1>&4 || {
+ status_=$(rungpg --status-fd 3 -q -d 3>&1 1>&4 || {
rc=$?
print_debug "rungpg failed with exit code $rc"
echo_info "ignoring GPG errors (likely anonymous recipient OR pinentry)."
done <<EOF
$1
EOF
-
- echo_git
+
+ echo_git
}
cleanup_tmpfiles()
#!/bin/sh
-
set -e
-: ${prefix:=/usr/local}
-: ${DESTDIR:=}
+: "${prefix:=/usr/local}"
+: "${DESTDIR:=}"
verbose() { echo "$@" >&2 && "$@"; }
-install_v()
-{
+
+install_v() {
# Install $1 into $2/ with mode $3
verbose install -d "$2" &&
- verbose install -m "$3" "$1" "$2"
+ verbose install -m "$3" "$1" "$2"
}
-install_v git-remote-gcrypt "$DESTDIR$prefix/bin" 755
+# --- VERSION DETECTION ---
+if [ -f /etc/os-release ]; then
+ . /etc/os-release
+ OS_IDENTIFIER=$ID # Linux
+elif command -v uname >/dev/null; then
+ # Fallback for macOS/BSD (darwin)
+ OS_IDENTIFIER=$(uname -s | tr '[:upper:]' '[:lower:]')
+else
+ OS_IDENTIFIER="unknown_OS"
+fi
+
+# Get base version then append OS identifier
+if [ -d .git ] && command -v git >/dev/null; then
+ VERSION=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "sha_unknown")
+else
+ if [ ! -f debian/changelog ]; then
+ echo "Error: debian/changelog not found (and not a git repo)" >&2
+ exit 1
+ fi
+ VERSION=$(grep ^git-remote-gcrypt debian/changelog | head -n 1 | awk '{print $2}' | tr -d '()')
+fi
+VERSION="$VERSION (deb running on $OS_IDENTIFIER)"
+
+echo "Detected version: $VERSION"
-if command -v rst2man >/dev/null
-then
+# Setup temporary build area
+BUILD_DIR="./.build_tmp"
+mkdir -p "$BUILD_DIR"
+trap 'rm -rf "$BUILD_DIR"' EXIT
+
+# Placeholder injection
+sed "s/@@DEV_VERSION@@/$VERSION/g" git-remote-gcrypt >"$BUILD_DIR/git-remote-gcrypt"
+
+# --- INSTALLATION ---
+# This is where the 'Permission denied' happens if not sudo
+install_v "$BUILD_DIR/git-remote-gcrypt" "$DESTDIR$prefix/bin" 755
+
+if command -v rst2man >/dev/null; then
rst2man='rst2man'
-elif command -v rst2man.py >/dev/null # it is installed as rst2man.py on macOS
-then
+elif command -v rst2man.py >/dev/null; then # it is installed as rst2man.py on macOS
rst2man='rst2man.py'
fi
-if [ -n "$rst2man" ]
-then
- trap 'rm -f git-remote-gcrypt.1.gz' EXIT
- verbose $rst2man ./README.rst | gzip -9 > git-remote-gcrypt.1.gz
+if [ -n "$rst2man" ]; then
+ # Update trap to clean up manpage too
+ trap 'rm -rf "$BUILD_DIR"; rm -f git-remote-gcrypt.1.gz' EXIT
+ verbose "$rst2man" ./README.rst | gzip -9 >git-remote-gcrypt.1.gz
install_v git-remote-gcrypt.1.gz "$DESTDIR$prefix/share/man/man1" 644
else
echo "'rst2man' not found, man page not installed" >&2
fi
+
+# Suggest installing shell completions
+cat >&2 <<EOF
+
+Installation complete!
+
+Optional: Install shell completions for tab completion:
+ Bash: completions/bash/git-remote-gcrypt
+ Zsh: completions/zsh/_git-remote-gcrypt
+ Fish: completions/fish/git-remote-gcrypt.fish
+
+See completions/README.rst for details.
+EOF
--- /dev/null
+# -*- coding: utf-8 -*-
+"""
+Created on Wed Dec 31 08:57:33 2025
+
+@author: shane
+"""
+
+import os
+import textwrap
+import xml.etree.ElementTree as E
+
+xml_file = os.environ.get("XML_FILE")
+patt = os.environ.get("PATT")
+
+tree = E.parse(xml_file)
+missed = []
+total_lines = 0
+missed_lines = 0
+
+for c in tree.findall(".//class"):
+ if patt in c.get("filename", ""):
+ for line in c.findall(".//line"):
+ total_lines += 1
+ if line.get("hits") == "0":
+ missed.append(line.get("number"))
+ missed_lines += 1
+
+if total_lines > 0:
+ COVERED = total_lines - missed_lines
+ pct = (COVERED / total_lines) * 100
+ COLOR = "\033[32;1m" if pct > 80 else "\033[33;1m" if pct > 50 else "\033[31;1m"
+ print(f"{COLOR}Coverage: {pct:.1f}% ({COVERED}/{total_lines})\033[0m")
+else:
+ print(f"Coverage: N/A (0 lines found for {patt})")
+
+if missed:
+ print(f"\033[31;1m{len(missed)} missing lines\033[0m in {patt}:")
+ print(
+ textwrap.fill(
+ ", ".join(missed), width=72, initial_indent=" ", subsequent_indent=" "
+ )
+ )
set -efuC -o pipefail
shopt -s inherit_errexit
+# Helpers
+print_info() { printf "\033[1;36m%s\033[0m\n" "$1"; }
+print_success() { printf "\033[1;34m✓ %s\033[0m\n" "$1"; }
+print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; }
+
+
+
+
# Unlike the main git-remote-gcrypt program, this testing script requires bash
# (rather than POSIX sh) and also depends on various common system utilities
# that the git-remote-gcrypt carefully avoids using (such as mktemp(1)).
readonly num_commits files_per_commit random_source random_data_per_file \
default_branch test_user_name test_user_email pack_size_limit
+print_info "Running system test..."
+
# Pipe text into this function to indent it with four spaces. This is used
# to make the output of this script prettier.
indent() {
assert() {
(set +e; [[ -n ${show_command:-} ]] && set -x; "${@}")
local -r status=${?}
- { [[ ${status} -eq 0 ]] && echo "Verification succeeded."; } || \
- echo "Verification failed."
+ { [[ ${status} -eq 0 ]] && print_success "Verification succeeded."; } || \
+ print_err "Verification failed."
return "${status}"
}
head -c "${random_data_size}" "${random_source}" > "${random_data_file}"
# Create gpg key and subkey.
-echo "Step 1: Creating a new GPG key and subkey to use for testing:"
+# Create gpg key and subkey.
+print_info "Step 1: Creating a new GPG key and subkey to use for testing:"
(
set -x
gpg --batch --passphrase "" --quick-generate-key \
###
section_break
-echo "Step 2: Creating new repository with random data:"
+print_info "Step 2: Creating new repository with random data:"
{
git init -- "${tempdir}/first"
cd "${tempdir}/first"
###
section_break
-echo "Step 3: Creating an empty bare repository to receive pushed data:"
+print_info "Step 3: Creating an empty bare repository to receive pushed data:"
git init --bare -- "${tempdir}/second.git" | indent
###
section_break
-echo "Step 4: Pushing the first repository to the second one using gitception:"
+print_info "Step 4: Pushing the first repository to the second one using gitception:"
{
# Note that when pushing to a bare local repository, git-remote-gcrypt uses
# gitception, rather than treating the remote as a local repository.
###
section_break
-echo "Step 5: Cloning the second repository using gitception:"
+print_info "Step 5: Cloning the second repository using gitception:"
{
(
set -x
show_command=1 assert diff -r --exclude ".git" -- \
"${tempdir}/first" "${tempdir}/third" 2>&1 | indent
} | indent
+
+[ -n "${COV_DIR:-}" ] && print_success "OK. Report: file://${COV_DIR}/index.html"
--- /dev/null
+#!/bin/bash
+set -u
+
+# 1. Setup Sandbox
+SANDBOX=$(mktemp -d)
+trap 'rm -rf "$SANDBOX"' EXIT
+
+# Helpers
+print_info() { printf "\033[1;36m[TEST] %s\033[0m\n" "$1"; }
+print_success() { printf "\033[1;34m[TEST] ✓ %s\033[0m\n" "$1"; }
+print_err() { printf "\033[1;31m[TEST] FAIL: %s\033[0m\n" "$1"; }
+
+print_info "Running install logic tests in $SANDBOX..."
+
+# 2. Copy artifacts
+cp git-remote-gcrypt "$SANDBOX"
+cp README.rst "$SANDBOX" 2>/dev/null || touch "$SANDBOX/README.rst"
+cp install.sh "$SANDBOX"
+cd "$SANDBOX" || exit 2
+
+# Ensure source binary has the placeholder for sed to work on
+# If your local git-remote-gcrypt already has a real version, sed won't find the tag
+if ! grep -q "@@DEV_VERSION@@" git-remote-gcrypt; then
+ echo 'VERSION="@@DEV_VERSION@@"' >git-remote-gcrypt
+fi
+chmod +x git-remote-gcrypt
+
+INSTALLER="./install.sh"
+
+assert_version() {
+ EXPECTED_SUBSTRING="$1"
+ PREFIX="$SANDBOX/usr"
+ export prefix="$PREFIX"
+ unset DESTDIR
+
+ # Run the installer
+ "$INSTALLER" >/dev/null 2>&1 || {
+ echo "Installer failed unexpectedly"
+ return 1
+ }
+
+ INSTALLED_BIN="$PREFIX/bin/git-remote-gcrypt"
+ chmod +x "$INSTALLED_BIN"
+
+ OUTPUT=$("$INSTALLED_BIN" --version 2>&1 </dev/null)
+
+ # CRITICAL: Use quotes around the variable to handle parentheses correctly
+ if [[ "$OUTPUT" != *"$EXPECTED_SUBSTRING"* ]]; then
+ print_err "FAILED: Expected '$EXPECTED_SUBSTRING' in output."
+ print_err " Got: '$OUTPUT'"
+ exit 1
+ else
+ printf " ✓ %s\n" "Found version '$EXPECTED_SUBSTRING'"
+ fi
+}
+
+# --- TEST 1: Strict Metadata Requirement ---
+echo "--- Test 1: Fail without Metadata ---"
+rm -rf debian redhat
+if "$INSTALLER" >/dev/null 2>&1; then
+ print_err "FAILED: Installer should have exited 1 without debian/changelog"
+ exit 1
+else
+ printf " ✓ %s\n" "Installer strictly requires metadata"
+fi
+
+# --- TEST 2: Debian-sourced Versioning ---
+echo "--- Test 2: Versioning from Changelog ---"
+mkdir -p debian
+echo "git-remote-gcrypt (5.5.5-1) unstable; urgency=low" >debian/changelog
+
+# Determine the OS identifier for the test expectation
+if [ -f /etc/os-release ]; then
+ # shellcheck source=/dev/null
+ source /etc/os-release
+ OS_IDENTIFIER="$ID"
+elif command -v uname >/dev/null; then
+ OS_IDENTIFIER=$(uname -s | tr '[:upper:]' '[:lower:]')
+else
+ OS_IDENTIFIER="unknown_os"
+fi
+
+# Use the identified OS for the expected string
+EXPECTED_TAG="5.5.5-1 (deb running on $OS_IDENTIFIER)"
+
+assert_version "$EXPECTED_TAG"
+
+# --- TEST 3: DESTDIR Support ---
+echo "--- Test 3: DESTDIR Support ---"
+rm -rf "${SANDBOX:?}/usr"
+export DESTDIR="$SANDBOX/pkg_root"
+export prefix="/usr"
+
+"$INSTALLER" >/dev/null 2>&1
+
+if [ -f "$SANDBOX/pkg_root/usr/bin/git-remote-gcrypt" ]; then
+ printf " ✓ %s\n" "DESTDIR honored"
+else
+ print_err "FAILED: Binary not found in DESTDIR"
+ exit 1
+fi
+
+print_success "All install logic tests passed."
+[ -n "${COV_DIR:-}" ] && print_success "OK. Report: file://${COV_DIR}/index.html"
+
+exit 0
--- /dev/null
+#!/bin/bash
+set -u
+
+# Helpers
+print_info() { printf "\033[1;36m%s\033[0m\n" "$1"; }
+print_success() { printf "\033[1;34m✓ %s\033[0m\n" "$1"; }
+print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; }
+
+print_info "Verifying system install..."
+
+# 1. Check if the command exists in the path
+if ! command -v git-remote-gcrypt >/dev/null; then
+ print_err "ERROR: git-remote-gcrypt is not in the PATH."
+ exit 1
+fi
+
+# 2. Run the version check (Capture stderr too!)
+OUTPUT=$(git-remote-gcrypt -v 2>&1)
+EXIT_CODE=$?
+
+if [ $EXIT_CODE -ne 0 ]; then
+ print_err "ERROR: Command exited with code $EXIT_CODE"
+ exit 1
+fi
+
+# 3. Verify the placeholder was replaced
+if [[ "$OUTPUT" == *"@@DEV_VERSION@@"* ]]; then
+ print_err "ERROR: Version placeholder @@DEV_VERSION@@ was not replaced!"
+ exit 1
+fi
+
+# 4. Determine expected ID for comparison to actual
+if [ -f /etc/os-release ]; then
+ # shellcheck source=/dev/null
+ source /etc/os-release
+ EXPECTED_ID=$ID
+elif command -v uname >/dev/null; then
+ EXPECTED_ID=$(uname -s | tr '[:upper:]' '[:lower:]')
+else
+ EXPECTED_ID="unknown_OS"
+fi
+
+if [[ "$OUTPUT" != *"(deb running on $EXPECTED_ID)"* ]]; then
+ print_err "ERROR: Distro ID '$EXPECTED_ID' missing from version string! (Got: $OUTPUT)"
+ exit 1
+fi
+
+# LEAD with the version success
+printf " ✓ %s\n" "VERSION OK: $OUTPUT"
+
+# 5. Verify the man page
+if man -w git-remote-gcrypt >/dev/null 2>&1; then
+ printf " ✓ %s\n" "DOCS OK: Man page is installed and indexed."
+else
+ print_err "ERROR: Man page not found in system paths."
+ exit 1
+fi
+
+print_success "INSTALLATION VERIFIED"