diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c458eb548..7081633b9e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,7 +43,7 @@ repos: - id: go-lint name: Go lint (PR changed code) - entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; merge_head=$(git rev-parse --verify MERGE_HEAD 2>/dev/null || true); base_tip=$(git rev-parse --verify origin/main 2>/dev/null || true); if [[ -n "$merge_head" && "$merge_head" == "$base_tip" ]]; then base="$merge_head"; else base=$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD); fi; cd apps/backend && golangci-lint run ./... --new-from-rev="$base" --timeout=5m' + entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; base=$(scripts/resolve-go-lint-base --comparison-base) || exit $?; cd apps/backend && golangci-lint run ./... --new-from-rev="$base" --timeout=5m' language: system files: ^apps/backend/.*\.go$ pass_filenames: false diff --git a/scripts/resolve-go-lint-base b/scripts/resolve-go-lint-base new file mode 100755 index 0000000000..7fa3b81968 --- /dev/null +++ b/scripts/resolve-go-lint-base @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# resolve-go-lint-base prints the local remote-tracking ref used by the +# pre-commit Go lint hook. Fork worktrees conventionally retain the canonical +# repository as `upstream`; do not silently fall back when the base is unclear. +set -euo pipefail + +canonical_repository='kdlbs/kandev' +comparison_base=false + +die() { + echo "Go lint base resolution failed: $*" >&2 + exit 1 +} + +case "${1:-}" in + '') ;; + --comparison-base) comparison_base=true; shift ;; + *) die "unknown argument: $1" ;; +esac + +[ "$#" -eq 0 ] || die "unexpected arguments: $*" + +normalize_remote_ref() { + local ref=$1 remote_branch remote branch normalized + case "$ref" in + refs/remotes/*) remote_branch=${ref#refs/remotes/} ;; + refs/*) die "base ref must be a remote-tracking ref: $ref" ;; + */*) remote_branch=$ref ;; + *) die "base ref must be a remote-tracking ref: $ref" ;; + esac + + remote=${remote_branch%%/*} + branch=${remote_branch#*/} + [ -n "$remote" ] && [ "$branch" != "$remote_branch" ] && [ -n "$branch" ] || die "base ref must name a remote and branch: $ref" + normalized="refs/remotes/$remote/$branch" + git check-ref-format "$normalized" >/dev/null 2>&1 || die "base ref does not have a safe ref format: $ref" + printf '%s\n' "$normalized" +} + +is_canonical_remote() { + local url=$1 path + case "$url" in + https://github.com/*) path=${url#https://github.com/} ;; + http://github.com/*) path=${url#http://github.com/} ;; + ssh://git@github.com/*) path=${url#ssh://git@github.com/} ;; + git+ssh://git@github.com/*) path=${url#git+ssh://git@github.com/} ;; + git://github.com/*) path=${url#git://github.com/} ;; + git@github.com:*) path=${url#git@github.com:} ;; + *) return 1 ;; + esac + path=${path%.git} + path=${path%/} + [ "$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]')" = "$canonical_repository" ] +} + +configured_refs=() +while IFS= read -r ref; do + [ -n "$ref" ] && configured_refs+=("$ref") +done < <(git config --get-all kandev.lintBaseRef || true) + +if [ "${#configured_refs[@]}" -gt 1 ]; then + die "ambiguous kandev.lintBaseRef values: ${configured_refs[*]}" +fi + +if [ "${#configured_refs[@]}" -eq 1 ]; then + base_ref=$(normalize_remote_ref "${configured_refs[0]}") +else + origin_head=$(git symbolic-ref --quiet refs/remotes/origin/HEAD || true) + [ -n "$origin_head" ] || die "origin's default branch is unavailable; set kandev.lintBaseRef to a local remote-tracking ref" + case "$origin_head" in + refs/remotes/origin/*) base_branch=${origin_head#refs/remotes/origin/} ;; + *) die "origin's default ref is invalid: $origin_head" ;; + esac + [ -n "$base_branch" ] || die "origin's default ref is invalid: $origin_head" + + if upstream_url=$(git remote get-url upstream 2>/dev/null); then + is_canonical_remote "$upstream_url" || die "upstream remote is not the canonical $canonical_repository repository" + base_ref=$(normalize_remote_ref "refs/remotes/upstream/$base_branch") + git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null || die "canonical upstream ref does not resolve locally: $base_ref" + else + base_ref=$(normalize_remote_ref "$origin_head") + fi +fi + +git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null || die "base ref does not resolve locally: $base_ref" + +if [ "$comparison_base" = true ]; then + merge_head=$(git rev-parse --verify MERGE_HEAD 2>/dev/null || true) + base_tip=$(git rev-parse --verify --quiet "$base_ref^{commit}") + if [ -n "$merge_head" ] && [ "$merge_head" = "$base_tip" ]; then + printf '%s\n' "$merge_head" + else + merge_base=$(git merge-base HEAD "$base_ref" 2>/dev/null) || die "base ref has no merge base with HEAD: $base_ref" + printf '%s\n' "$merge_base" + fi +else + printf '%s\n' "$base_ref" +fi diff --git a/scripts/resolve-go-lint-base.test.sh b/scripts/resolve-go-lint-base.test.sh new file mode 100755 index 0000000000..148153195d --- /dev/null +++ b/scripts/resolve-go-lint-base.test.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# resolve-go-lint-base.test.sh — regression coverage for the Go lint hook's +# comparison base. A fork's origin can be stale, so the resolver must prefer +# the conventional canonical upstream ref without weakening lint enforcement. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RESOLVER="$ROOT_DIR/scripts/resolve-go-lint-base" +status=0 + +fail() { + printf 'FAIL %s\n' "$*" + status=1 +} + +expect_eq() { + local label=$1 want=$2 got=$3 + if [ "$got" = "$want" ]; then + printf 'ok %-34s %s\n' "$label" "$got" + else + fail "$label (want $want, got $got)" + fi +} + +expect_fail() { + local label=$1 needle=$2 repo=$3 output + if output=$(cd "$repo" && "$RESOLVER" 2>&1); then + fail "$label (unexpected success: $output)" + elif [[ "$output" == *"$needle"* ]]; then + printf 'ok %-34s %s\n' "$label" "$needle" + else + fail "$label (expected $needle in: $output)" + fi +} + +new_repo() { + local repo + repo=$(mktemp -d) + git -C "$repo" init -q + git -C "$repo" config user.email test@example.com + git -C "$repo" config user.name 'Hook test' + git -C "$repo" commit --allow-empty -qm initial + git -C "$repo" branch -M main + git -C "$repo" remote add origin https://example.test/fork.git + git -C "$repo" update-ref refs/remotes/origin/main HEAD + git -C "$repo" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main + echo "$repo" +} + +fork_repo="" single_repo="" release_repo="" configured_repo="" +dangling_repo="" ambiguous_repo="" unsafe_repo="" history_repo="" missing_repo="" +full_ref_repo="" collision_repo="" untrusted_repo="" namespace_repo="" +diverged_repo="" merge_repo="" +cleanup() { + local repo + for repo in "$fork_repo" "$single_repo" "$release_repo" "$configured_repo" \ + "$dangling_repo" "$ambiguous_repo" "$unsafe_repo" "$history_repo" "$missing_repo" \ + "$full_ref_repo" "$collision_repo" "$untrusted_repo" "$namespace_repo" \ + "$diverged_repo" "$merge_repo"; do + [ -z "$repo" ] || rm -rf "$repo" + done +} +trap cleanup EXIT + +fork_repo=$(new_repo) +git -C "$fork_repo" remote add upstream git@github.com:kdlbs/kandev.git +git -C "$fork_repo" update-ref refs/remotes/upstream/main HEAD +expect_eq 'fork prefers canonical upstream' 'refs/remotes/upstream/main' "$(cd "$fork_repo" && "$RESOLVER")" + +single_repo=$(new_repo) +expect_eq 'single remote uses origin default' 'refs/remotes/origin/main' "$(cd "$single_repo" && "$RESOLVER")" + +release_repo=$(new_repo) +git -C "$release_repo" update-ref -d refs/remotes/origin/main +git -C "$release_repo" update-ref refs/remotes/origin/release HEAD +git -C "$release_repo" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/release +git -C "$release_repo" remote add upstream https://github.com/kdlbs/kandev.git +git -C "$release_repo" update-ref refs/remotes/upstream/release HEAD +expect_eq 'non-main default follows upstream' 'refs/remotes/upstream/release' "$(cd "$release_repo" && "$RESOLVER")" + +configured_repo=$(new_repo) +git -C "$configured_repo" remote add upstream https://github.com/kdlbs/kandev.git +git -C "$configured_repo" update-ref refs/remotes/upstream/release HEAD +git -C "$configured_repo" config kandev.lintBaseRef upstream/release +expect_eq 'configured ref is honored' 'refs/remotes/upstream/release' "$(cd "$configured_repo" && "$RESOLVER")" + +dangling_repo=$(new_repo) +git -C "$dangling_repo" config kandev.lintBaseRef upstream/main +expect_fail 'missing configured ref fails closed' 'does not resolve' "$dangling_repo" + +ambiguous_repo=$(new_repo) +git -C "$ambiguous_repo" config --add kandev.lintBaseRef origin/main +git -C "$ambiguous_repo" config --add kandev.lintBaseRef upstream/main +expect_fail 'multiple configured refs fail closed' 'ambiguous' "$ambiguous_repo" + +missing_repo=$(new_repo) +git -C "$missing_repo" update-ref -d refs/remotes/origin/main +expect_fail 'missing default ref fails closed' 'does not resolve' "$missing_repo" + +unsafe_repo=$(new_repo) +git -C "$unsafe_repo" config kandev.lintBaseRef 'origin/main --not-a-ref' +expect_fail 'unsafe configured ref is rejected' 'safe ref format' "$unsafe_repo" + +history_repo=$(new_repo) +git -C "$history_repo" branch previous +git -C "$history_repo" switch -q previous +git -C "$history_repo" switch -q main +git -C "$history_repo" config kandev.lintBaseRef '@{-1}' +expect_fail 'checkout history is not a base ref' 'remote-tracking' "$history_repo" + +full_ref_repo=$(new_repo) +expect_eq 'resolver emits fully qualified ref' 'refs/remotes/origin/main' "$(cd "$full_ref_repo" && "$RESOLVER")" + +collision_repo=$(new_repo) +git -C "$collision_repo" tag origin/main +expect_eq 'tag collision still selects remote ref' 'refs/remotes/origin/main' "$(cd "$collision_repo" && "$RESOLVER")" + +untrusted_repo=$(new_repo) +git -C "$untrusted_repo" remote add upstream https://example.test/not-kandev.git +git -C "$untrusted_repo" update-ref refs/remotes/upstream/main HEAD +expect_fail 'untrusted upstream fails closed' 'canonical' "$untrusted_repo" + +namespace_repo=$(new_repo) +git -C "$namespace_repo" config kandev.lintBaseRef refs/heads/main +expect_fail 'configured local branch is rejected' 'remote-tracking' "$namespace_repo" + +diverged_repo=$(new_repo) +git -C "$diverged_repo" branch feature +git -C "$diverged_repo" switch -q feature +git -C "$diverged_repo" commit --allow-empty -qm feature +git -C "$diverged_repo" switch -q main +git -C "$diverged_repo" commit --allow-empty -qm base-advance +diverged_base=$(git -C "$diverged_repo" rev-parse HEAD) +diverged_fork=$(git -C "$diverged_repo" rev-parse HEAD~1) +git -C "$diverged_repo" update-ref refs/remotes/origin/main "$diverged_base" +git -C "$diverged_repo" switch -q feature +expect_eq 'diverged history uses merge base' "$diverged_fork" "$(cd "$diverged_repo" && "$RESOLVER" --comparison-base)" + +merge_repo=$(new_repo) +git -C "$merge_repo" branch feature +git -C "$merge_repo" switch -q feature +git -C "$merge_repo" commit --allow-empty -qm feature +git -C "$merge_repo" switch -q main +git -C "$merge_repo" commit --allow-empty -qm base-advance +merge_base_tip=$(git -C "$merge_repo" rev-parse HEAD) +git -C "$merge_repo" update-ref refs/remotes/origin/main "$merge_base_tip" +git -C "$merge_repo" switch -q feature +printf '%s\n' "$merge_base_tip" > "$merge_repo/.git/MERGE_HEAD" +expect_eq 'base merge keeps incoming tip' "$merge_base_tip" "$(cd "$merge_repo" && "$RESOLVER" --comparison-base)" + +if [ "$status" -eq 0 ]; then + echo 'All Go lint base resolver checks passed.' +fi +exit "$status"