From cd5e38b235106df3e9d85ebc94ef56859a593b04 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 19:47:46 -0500 Subject: [PATCH 01/13] feat(task-1): create pre-push docs drift hook --- scripts/git-hooks/pre-push | 105 +++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100755 scripts/git-hooks/pre-push diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100755 index 000000000..b674664cb --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +ZERO_SHA="0000000000000000000000000000000000000000" + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [ -z "$repo_root" ]; then + exit 0 +fi +cd "$repo_root" + +require_docs_drift_tools() { + if ! command -v yq >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then + echo "docs-drift: yq and jq required (install them, or set KASMOS_SKIP_DOCS_DRIFT=1)" >&2 + exit 1 + fi +} + +has_docs_drift_skip_trailer() { + local base="$1" + local local_sha="$2" + + git log --format=%B "$base..$local_sha" 2>/dev/null | grep -E -q '^Docs-Drift-Skip:' +} + +docs_drift_skip_reason() { + local base="$1" + local local_sha="$2" + + git log --format=%B "$base..$local_sha" 2>/dev/null | grep -E '^Docs-Drift-Skip:' | head -1 +} + +print_docs_drift_rejection() { + local report="$1" + + { + echo "docs-drift: push blocked - code changed without matching docs" + echo + jq -r ' + .drift[] + | .code_changed as $code + | .docs_not_changed as $docs + | $code, + " expected docs:", + ($docs[] | " \(.)"), + "" + ' <<<"$report" + echo "bypass options (use sparingly):" + echo " git push --no-verify" + echo " KASMOS_SKIP_DOCS_DRIFT=1 git push" + echo " commit with trailer: Docs-Drift-Skip: " + } >&2 +} + +while read -r local_ref local_sha remote_ref remote_sha; do + if [ -z "${local_ref:-}" ]; then + continue + fi + + if [ "$local_sha" = "$ZERO_SHA" ]; then + continue + fi + + case "$remote_ref" in + refs/tags/*) + continue + ;; + esac + + if [ "${KASMOS_SKIP_DOCS_DRIFT:-}" = "1" ]; then + echo "docs-drift: bypassed via KASMOS_SKIP_DOCS_DRIFT=1" >&2 + continue + fi + + if [ "$remote_sha" = "$ZERO_SHA" ]; then + default_branch="origin/${KASMOS_DEFAULT_BRANCH:-main}" + base="$(git merge-base "$local_sha" "$default_branch" 2>/dev/null || true)" + if [ -z "$base" ]; then + echo "docs-drift: warning: unable to find merge-base for $local_ref and $default_branch; skipping docs drift check" >&2 + continue + fi + else + base="$remote_sha" + fi + + if has_docs_drift_skip_trailer "$base" "$local_sha"; then + reason="$(docs_drift_skip_reason "$base" "$local_sha")" + echo "docs-drift: bypassed via trailer (${reason})" >&2 + continue + fi + + require_docs_drift_tools + + if [ -z "$(git diff --name-only "$base..$local_sha")" ]; then + continue + fi + + REPORT="$(BASE_REF="$base" bash scripts/detect-docs-drift.sh)" + if [ "$(jq '.drift | length' <<<"$REPORT")" != "0" ]; then + print_docs_drift_rejection "$REPORT" + exit 1 + fi +done + +exit 0 From dbd2d79112c9bd6ed5be1be6b07a40f884dfb629 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 19:48:12 -0500 Subject: [PATCH 02/13] feat(task-2): add hook install path --- Justfile | 4 ++++ Makefile | 5 ++++- scripts/git-hooks/install.sh | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100755 scripts/git-hooks/install.sh diff --git a/Justfile b/Justfile index 682dcbbf7..4ae766231 100644 --- a/Justfile +++ b/Justfile @@ -131,6 +131,10 @@ db-service-enable: db-service-install # Install and start both user services services-enable: kasmosd-enable db-service-enable +# Install the docs-drift pre-push git hook +hooks *args: + bash scripts/git-hooks/install.sh {{args}} + # run with no args bin: kas diff --git a/Makefile b/Makefile index 7ac21383a..35bb0b0d1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run build test test-fast test-full test-race bench-tests clean +.PHONY: run build test test-fast test-full test-race bench-tests clean hooks run: build ./kas $(ARGS) @@ -20,5 +20,8 @@ test-race: bench-tests: ./scripts/bench_tests.sh +hooks: + bash scripts/git-hooks/install.sh + clean: rm -f kas kasmos diff --git a/scripts/git-hooks/install.sh b/scripts/git-hooks/install.sh new file mode 100755 index 000000000..5378bf7f9 --- /dev/null +++ b/scripts/git-hooks/install.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +force=0 +if [ "${1:-}" = "--force" ]; then + force=1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +if [ ! -f "$repo_root/docs/docs-drift-map.yml" ]; then + echo "error: scripts/git-hooks/install.sh must run inside a kasmos clone (missing docs/docs-drift-map.yml)" >&2 + exit 1 +fi + +current="$(git config --get core.hooksPath || true)" +if [ -n "$current" ] && [ "$current" != "scripts/git-hooks" ] && [ "$force" -ne 1 ]; then + echo "error: core.hooksPath is already set to '$current'." >&2 + echo "refusing to overwrite. re-run with --force, or unset manually:" >&2 + echo " git config --unset core.hooksPath" >&2 + exit 1 +fi + +git config core.hooksPath scripts/git-hooks +chmod +x scripts/git-hooks/pre-push +git fetch origin "${KASMOS_DEFAULT_BRANCH:-main}" --quiet || true + +echo "installed: core.hooksPath=scripts/git-hooks" From 3f18855e665b9ec6513cc2183cbc6612ce71fa4a Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 19:50:19 -0500 Subject: [PATCH 03/13] feat(task-3): add docs drift hook harnesses --- scripts/git-hooks/test/run.sh | 307 ++++++++++++++++++++++++++++++++ scripts/git-hooks/test/smoke.sh | 93 ++++++++++ 2 files changed, 400 insertions(+) create mode 100755 scripts/git-hooks/test/run.sh create mode 100755 scripts/git-hooks/test/smoke.sh diff --git a/scripts/git-hooks/test/run.sh b/scripts/git-hooks/test/run.sh new file mode 100755 index 000000000..110a6fa0b --- /dev/null +++ b/scripts/git-hooks/test/run.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +HOOK_SRC="$ROOT/scripts/git-hooks/pre-push" +DETECTOR_SRC="$ROOT/scripts/detect-docs-drift.sh" +MAP_SRC="$ROOT/docs/docs-drift-map.yml" +ZERO_SHA="0000000000000000000000000000000000000000" + +SCENARIO_TMP="" +SCENARIO_STDERR="" +SCENARIO_STATUS=0 +SCENARIO_PATH="" + +fail() { + printf '%s\n' "$1" + return 1 +} + +git_commit() { + git -c user.email=test@test -c user.name=test commit -q -m "$1" +} + +seed_repo() { + local repo + repo="$(mktemp -d)" + git -C "$repo" init -q --initial-branch=main + + mkdir -p "$repo/docs" "$repo/scripts" "$repo/scripts/git-hooks" \ + "$repo/cmd" "$repo/web/docs/docs/cli-reference" "$repo/test-bin" + cp "$MAP_SRC" "$repo/docs/docs-drift-map.yml" + cp "$DETECTOR_SRC" "$repo/scripts/detect-docs-drift.sh" + chmod +x "$repo/scripts/detect-docs-drift.sh" + write_yq_adapter "$repo/test-bin/yq" + + if [ ! -f "$HOOK_SRC" ]; then + fail "missing scripts/git-hooks/pre-push; Task 1 must land before behavioral scenarios can run" + return 1 + fi + cp "$HOOK_SRC" "$repo/scripts/git-hooks/pre-push" + chmod +x "$repo/scripts/git-hooks/pre-push" + + printf 'package main\n' >"$repo/cmd/task.go" + printf '# task docs\n' >"$repo/web/docs/docs/cli-reference/task.mdx" + printf '# index docs\n' >"$repo/web/docs/docs/cli-reference/index.mdx" + git -C "$repo" add docs scripts cmd web + git -C "$repo" -c user.email=test@test -c user.name=test commit -q -m "initial" + printf '%s\n' "$repo" +} + +write_yq_adapter() { + local dest="$1" + cat >"$dest" <<'YQ' +#!/usr/bin/env bash +set -euo pipefail + +if [ "${1:-}" = "e" ] && [ "${2:-}" = "-o=json" ] && [ "${3:-}" = "-I=0" ] && [ "${4:-}" = ".[]" ]; then + python3 - "$5" <<'PY' +import json +import sys +import yaml + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) +for entry in data: + print(json.dumps(entry, separators=(",", ":"))) +PY + exit 0 +fi + +if [ "${1:-}" = "-r" ]; then + jq -r "$2" + exit 0 +fi + +echo "unsupported test yq invocation: $*" >&2 +exit 2 +YQ + chmod +x "$dest" +} + +run_hook() { + local repo="$1" + local stdin_line="$2" + shift 2 + local hook_path="${SCENARIO_PATH:-$repo/test-bin:$PATH}" + + SCENARIO_STDERR="$repo/stderr.txt" + SCENARIO_STATUS=0 + ( + cd "$repo" + "$@" PATH="$hook_path" bash scripts/git-hooks/pre-push >/dev/null 2>"$SCENARIO_STDERR" <<<"$stdin_line" + ) || SCENARIO_STATUS=$? +} + +assert_status() { + local expected="$1" + if [ "$SCENARIO_STATUS" -ne "$expected" ]; then + fail "expected exit $expected, got $SCENARIO_STATUS; stderr: $(tr '\n' ' ' <"$SCENARIO_STDERR")" + return 1 + fi +} + +assert_stderr_contains() { + local expected="$1" + if ! rg -q --fixed-strings "$expected" "$SCENARIO_STDERR"; then + fail "stderr missing '$expected'; got: $(tr '\n' ' ' <"$SCENARIO_STDERR")" + return 1 + fi +} + +assert_stderr_empty() { + if [ -s "$SCENARIO_STDERR" ]; then + fail "expected empty stderr, got: $(tr '\n' ' ' <"$SCENARIO_STDERR")" + return 1 + fi +} + +make_drift_commit() { + local repo="$1" + printf '\nfunc changed() {}\n' >>"$repo/cmd/task.go" + git -C "$repo" add cmd/task.go + git -C "$repo" -c user.email=test@test -c user.name=test commit -q -m "${2:-change task cli}" +} + +make_docs_commit() { + local repo="$1" + printf '\nfunc changed() {}\n' >>"$repo/cmd/task.go" + printf '\nupdated task docs\n' >>"$repo/web/docs/docs/cli-reference/task.mdx" + git -C "$repo" add cmd/task.go web/docs/docs/cli-reference/task.mdx + git -C "$repo" -c user.email=test@test -c user.name=test commit -q -m "change task cli and docs" +} + +with_feature_branch() { + local repo="$1" + git -C "$repo" switch -q -c feature +} + +scenario_drift_detected() { + local repo remote local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + with_feature_branch "$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + make_drift_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $remote" env + assert_status 1 || return 1 + assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" +} + +scenario_drift_absent() { + local repo remote local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + with_feature_branch "$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + make_docs_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $remote" env + assert_status 0 || return 1 + assert_stderr_empty +} + +scenario_deletion_ref() { + local repo remote + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + with_feature_branch "$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $ZERO_SHA refs/heads/feature $remote" env + assert_status 0 || return 1 + assert_stderr_empty +} + +scenario_tag_push() { + local repo remote local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + git -C "$repo" tag v2.9.0 + local_sha="$(git -C "$repo" rev-parse v2.9.0)" + run_hook "$repo" "refs/tags/v2.9.0 $local_sha refs/tags/v2.9.0 $remote" env + assert_status 0 || return 1 + assert_stderr_empty +} + +scenario_new_branch() { + local repo base local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + base="$(git -C "$repo" rev-parse HEAD)" + git -C "$repo" update-ref refs/remotes/origin/main "$base" + with_feature_branch "$repo" + make_drift_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $ZERO_SHA" env + assert_status 1 || return 1 + assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" +} + +scenario_bypass_env() { + local repo remote local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + with_feature_branch "$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + make_drift_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $remote" env KASMOS_SKIP_DOCS_DRIFT=1 + assert_status 0 || return 1 + assert_stderr_contains "bypassed via KASMOS_SKIP_DOCS_DRIFT" +} + +scenario_bypass_trailer() { + local repo remote local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + with_feature_branch "$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + make_drift_commit "$repo" $'change task cli\n\nDocs-Drift-Skip: ticket-123' + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $remote" env + assert_status 0 || return 1 + assert_stderr_contains "bypassed via trailer" || return 1 + assert_stderr_contains "ticket-123" +} + +scenario_missing_yq() { + local repo remote local_sha mask_path + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + with_feature_branch "$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + make_drift_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + + mask_path="$repo/path-without-yq" + mkdir -p "$mask_path" + # Keep the hook runnable with a narrow PATH, but intentionally do not provide yq. + ln -s "$(command -v bash)" "$mask_path/bash" + ln -s "$(command -v git)" "$mask_path/git" + ln -s "$(command -v jq)" "$mask_path/jq" + ln -s "$(command -v env)" "$mask_path/env" + ln -s "$(command -v dirname)" "$mask_path/dirname" + ln -s "$(command -v mktemp)" "$mask_path/mktemp" + ln -s "$(command -v rm)" "$mask_path/rm" + ln -s "$(command -v tr)" "$mask_path/tr" + ln -s "$(command -v grep)" "$mask_path/grep" + ln -s "$(command -v head)" "$mask_path/head" + + SCENARIO_PATH="$mask_path" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $remote" env + assert_status 1 || return 1 + assert_stderr_contains "yq and jq required" +} + +run_scenario() { + local name="$1" + local fn="$2" + SCENARIO_TMP="" + SCENARIO_STDERR="" + SCENARIO_STATUS=0 + SCENARIO_PATH="" + + local message + if message="$($fn 2>&1)"; then + printf 'PASS %s\n' "$name" + [ -z "$SCENARIO_TMP" ] || rm -rf "$SCENARIO_TMP" + return 0 + fi + + printf 'FAIL %s %s\n' "$name" "$message" + [ -z "$SCENARIO_TMP" ] || rm -rf "$SCENARIO_TMP" + return 1 +} + +main() { + local passed=0 + local total=8 + + local scenarios=( + "drift_detected:scenario_drift_detected" + "drift_absent:scenario_drift_absent" + "deletion_ref:scenario_deletion_ref" + "tag_push:scenario_tag_push" + "new_branch:scenario_new_branch" + "bypass_env:scenario_bypass_env" + "bypass_trailer:scenario_bypass_trailer" + "missing_yq:scenario_missing_yq" + ) + + for scenario in "${scenarios[@]}"; do + local name="${scenario%%:*}" + local fn="${scenario#*:}" + if run_scenario "$name" "$fn"; then + passed=$((passed + 1)) + continue + fi + printf 'RESULT: %d/%d passed\n' "$passed" "$total" + exit 1 + done + + printf 'RESULT: %d/%d passed\n' "$passed" "$total" +} + +main "$@" diff --git a/scripts/git-hooks/test/smoke.sh b/scripts/git-hooks/test/smoke.sh new file mode 100755 index 000000000..bd7a283e8 --- /dev/null +++ b/scripts/git-hooks/test/smoke.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +HOOK="$ROOT/scripts/git-hooks/pre-push" +DETECTOR="$ROOT/scripts/detect-docs-drift.sh" +TMP_DIR="" + +if [ ! -f "$HOOK" ]; then + echo "missing scripts/git-hooks/pre-push; Task 1 must land before smoke can run" >&2 + exit 1 +fi + +head_sha="$(git -C "$ROOT" rev-parse HEAD)" +base_sha="$(git -C "$ROOT" rev-parse origin/main)" +stdin_line="$(printf 'refs/heads/HEAD %s refs/heads/main %s\n' "$head_sha" "$base_sha")" +stderr_file="$(mktemp)" +trap 'rm -f "$stderr_file"; [ -z "$TMP_DIR" ] || rm -rf "$TMP_DIR"' EXIT + +ensure_detector_yq() { + if (cd "$ROOT" && yq e -o=json -I=0 '.[]' docs/docs-drift-map.yml >/dev/null 2>&1); then + return 0 + fi + + TMP_DIR="$(mktemp -d)" + cat >"$TMP_DIR/yq" <<'YQ' +#!/usr/bin/env bash +set -euo pipefail + +if [ "${1:-}" = "e" ] && [ "${2:-}" = "-o=json" ] && [ "${3:-}" = "-I=0" ] && [ "${4:-}" = ".[]" ]; then + python3 - "$5" <<'PY' +import json +import sys +import yaml + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) +for entry in data: + print(json.dumps(entry, separators=(",", ":"))) +PY + exit 0 +fi + +if [ "${1:-}" = "-r" ]; then + jq -r "$2" + exit 0 +fi + +echo "unsupported test yq invocation: $*" >&2 +exit 2 +YQ + chmod +x "$TMP_DIR/yq" + PATH="$TMP_DIR:$PATH" + export PATH +} + +ensure_detector_yq + +hook_status=0 +( + cd "$ROOT" + bash "$HOOK" >/dev/null 2>"$stderr_file" <<<"$stdin_line" +) || hook_status=$? + +drift_count="$( + cd "$ROOT" + BASE_REF="$base_sha" bash "$DETECTOR" | jq '.drift | length' +)" + +if [ "$drift_count" -eq 0 ]; then + if [ "$hook_status" -ne 0 ]; then + echo "expected hook success for zero drift entries, got exit $hook_status" >&2 + tr '\n' ' ' <"$stderr_file" >&2 + echo >&2 + exit 1 + fi + echo "smoke passed: hook allowed push and detector reported zero drift entries" + exit 0 +fi + +if [ "$hook_status" -eq 0 ]; then + echo "expected hook failure for $drift_count drift entries, got success" >&2 + exit 1 +fi + +if ! rg -q --fixed-strings "docs-drift: push blocked" "$stderr_file"; then + echo "expected hook stderr to contain docs-drift block message" >&2 + tr '\n' ' ' <"$stderr_file" >&2 + echo >&2 + exit 1 +fi + +echo "smoke passed: hook blocked push and detector reported $drift_count drift entries" From 79bd589a30344b7877047de2cee2bf86011b353d Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 19:53:21 -0500 Subject: [PATCH 04/13] feat(task-6): document docs drift hook --- CLAUDE.md | 1 + scripts/git-hooks/README.md | 42 +++++++++++++++++++ .../docs/contributing/development-setup.mdx | 24 +++++++++++ 3 files changed, 67 insertions(+) create mode 100644 scripts/git-hooks/README.md diff --git a/CLAUDE.md b/CLAUDE.md index e3b133a64..2dca0ae20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ Key points: - **Arrow-key navigation in overlays**: use ↑↓ for navigation, not j/k vim bindings. Letter keys should always type into search/filter when present. - Signals are gateway-backed first. `.kasmos/signals/` still exists for compatibility, but do not document filesystem sentinels as the primary lifecycle path. - **Daemon runs via systemd.** The kasmos daemon and DB server run as `systemctl --user` services (`kasmos` and `kasmosdb`). Always use `systemctl --user restart kasmos` (not `kas daemon start`). The CLI commands (`kas daemon start/stop`) exist for development and CI only. +- **Docs drift is blocked at push-time** by `scripts/git-hooks/pre-push`. Run `just hooks` once after cloning. CI enforces the same check as a required status, so `--no-verify` lands you in a failing PR. ## MCP-First Tooling diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md new file mode 100644 index 000000000..cca8bc74f --- /dev/null +++ b/scripts/git-hooks/README.md @@ -0,0 +1,42 @@ +# kasmos git hooks + +This directory holds the checked-in client-side git hooks for kasmos contributors. Install with `just hooks` (sets `git config core.hooksPath`). + +## pre-push + +Blocks `git push` when code changes in the push range have no matching documentation edits, per `docs/docs-drift-map.yml`. Reuses `scripts/detect-docs-drift.sh` and the same drift map that CI uses. + +### stdin protocol + +`git push` invokes `pre-push` with one line per ref being pushed, on stdin: + +``` + +``` + +The hook iterates every line. Special cases: + +- `` is the all-zero sha → ref deletion, allow. +- `` matches `refs/tags/*` → tag push, allow. +- `` is the all-zero sha → new branch, compare against `origin/${KASMOS_DEFAULT_BRANCH:-main}`. + +### bypass + +| how | when | +|-----|------| +| `git push --no-verify` | one-off escape, skips all client hooks | +| `KASMOS_SKIP_DOCS_DRIFT=1` env | scoped to docs-drift only | +| `Docs-Drift-Skip: ` commit trailer | auditable, preserved in history | + +CI runs the same check as a required status. Bypassing the hook does not bypass CI. + +### dependencies + +`bash`, `git`, `yq`, `jq` — all required by `scripts/detect-docs-drift.sh`. The hook hard-fails with an install hint if any are missing. + +### tests + +- `scripts/git-hooks/test/run.sh` — synthetic-repo unit scenarios (8 cases, hermetic). +- `scripts/git-hooks/test/smoke.sh` — runs hook against the real repo HEAD and asserts agreement with the detector. + +Both are invoked from `.github/workflows/docs-drift.yml`. diff --git a/web/docs/docs/contributing/development-setup.mdx b/web/docs/docs/contributing/development-setup.mdx index b97a6799d..0b8583886 100644 --- a/web/docs/docs/contributing/development-setup.mdx +++ b/web/docs/docs/contributing/development-setup.mdx @@ -125,3 +125,27 @@ kasmos scaffolds agent harness configuration into `.agents/`, `.claude/`, and `. ```bash kas check -v ``` + +## docs-drift hook (pre-push) + +The kasmos repo blocks `git push` when code changes have no matching docs edits. The map lives at `docs/docs-drift-map.yml`; the hook lives at `scripts/git-hooks/pre-push`. + +Install the hook once per clone: + +```bash +just hooks +# or, without just: +bash scripts/git-hooks/install.sh +``` + +This sets `git config core.hooksPath scripts/git-hooks`. If you already use a custom `core.hooksPath` (e.g. husky, lefthook), the installer refuses to overwrite — pass `--force` to override. + +### bypass options + +When the hook flags a false positive (typically a pure refactor), three escape hatches are available — use sparingly: + +- `git push --no-verify` — skips all client-side hooks for this push. +- `KASMOS_SKIP_DOCS_DRIFT=1 git push` — skips just the docs-drift check, leaves other hooks active. +- `Docs-Drift-Skip: ` commit trailer on any commit in the push range — auditable, preserved in history. + +CI runs the same check as a required status, so `--no-verify` lands you in a failing PR. Maintainers can override at branch protection level. From 0a570c9b6e5a42a80fe3aa5c41f21b0a5d21f999 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 19:53:31 -0500 Subject: [PATCH 05/13] feat(task-4): enforce docs drift workflow Merging maintainer must mark the Docs drift check as required in GitHub branch protection settings. --- .github/workflows/docs-drift.yml | 90 ++++++-------------------------- 1 file changed, 17 insertions(+), 73 deletions(-) diff --git a/.github/workflows/docs-drift.yml b/.github/workflows/docs-drift.yml index f89dcc374..62357522b 100644 --- a/.github/workflows/docs-drift.yml +++ b/.github/workflows/docs-drift.yml @@ -1,8 +1,6 @@ name: Docs drift on: - schedule: - - cron: '17 4 * * *' pull_request: paths: - 'cmd/**.go' @@ -13,13 +11,8 @@ on: - 'web/docs/docs/**' workflow_dispatch: -env: - ENABLE_COPILOT_ASSIGN: "true" - permissions: - contents: write pull-requests: write - issues: write jobs: detect: @@ -44,11 +37,13 @@ jobs: run: | set -euo pipefail REPORT=$(bash scripts/detect-docs-drift.sh) - echo "report<> "$GITHUB_OUTPUT" - echo "$REPORT" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" + { + echo "report<> "$GITHUB_OUTPUT" - comment-on-pr: + pr-check: if: github.event_name == 'pull_request' needs: detect runs-on: ubuntu-latest @@ -69,7 +64,9 @@ jobs: \`\`\`json ${JSON.stringify(report, null, 2)} - \`\`\``; + \`\`\` + + bypass: see scripts/git-hooks/README.md`; const comments = await github.paginate(github.rest.issues.listComments, { issue_number: context.issue.number, @@ -97,68 +94,15 @@ jobs: }); } - open-or-update-pr: - if: github.event_name != 'pull_request' - needs: detect + core.setFailed("docs drift detected"); + + hook-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Open or update tracking PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPORT: ${{ needs.detect.outputs.report }} - run: | - set -euo pipefail - if [ "$(jq '.drift | length' <<<"$REPORT")" = "0" ]; then - echo "no drift detected" - exit 0 - fi - - BRANCH=bot/docs-drift - TODAY=$(date -u +%Y-%m-%d) - git config user.name "kasmos-docs-bot" - git config user.email "docs-bot@users.noreply.github.com" - - if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then - git fetch origin "$BRANCH:$BRANCH" - git checkout "$BRANCH" - else - git checkout -B "$BRANCH" - fi - - git commit --allow-empty -m "docs: refresh from code drift ($TODAY)" - git push origin "$BRANCH" - - BODY=$(cat </dev/null || true - - assignee_args=() - if [ "$ENABLE_COPILOT_ASSIGN" = "true" ] && gh api "/repos/${{ github.repository }}/assignees?per_page=100" 2>/dev/null | jq -e '.[] | select(.login == "copilot")' >/dev/null; then - assignee_args=(--assignee copilot) - fi - - if [ "$(gh pr view "$BRANCH" --json state -q .state 2>/dev/null || true)" = "OPEN" ]; then - gh pr edit "$BRANCH" --body "$BODY" --add-label docs-drift - if [ "${#assignee_args[@]}" -gt 0 ]; then - gh pr edit "$BRANCH" --add-assignee copilot - fi - else - gh pr create --head "$BRANCH" --base main \ - --title "docs: refresh from code drift ($TODAY)" \ - --body "$BODY" \ - --label docs-drift \ - "${assignee_args[@]}" - fi + - name: Run hook unit scenarios + run: bash scripts/git-hooks/test/run.sh + + - name: Run hook smoke harness + run: bash scripts/git-hooks/test/smoke.sh From 14c683065f9a8029b8b0efa19acbf2f9d1247bca Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 19:55:15 -0500 Subject: [PATCH 06/13] feat(task-5): report missing pre-push hook --- cmd/kas/check.go | 16 ++++++++ cmd/kas/check_test.go | 39 +++++++++++++++++++ internal/check/check.go | 11 ++++++ internal/check/githooks.go | 67 +++++++++++++++++++++++++++++++++ internal/check/githooks_test.go | 56 +++++++++++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 internal/check/githooks.go create mode 100644 internal/check/githooks_test.go diff --git a/cmd/kas/check.go b/cmd/kas/check.go index 769c94525..0d0269bbe 100644 --- a/cmd/kas/check.go +++ b/cmd/kas/check.go @@ -84,6 +84,9 @@ func runCheck(cmd *cobra.Command, args []string) error { defer probeCancel() mcpProbeErr := probeSharedMCPFunc(probeCtx) renderMCPEndpoint(cmd, mcpProbeErr) + if result.GitHooks != nil { + renderGitHooks(cmd, result.GitHooks) + } // Detect long-lived stdio mcp subprocesses (threshold: 60 s). mcpProcs, _ := check.ListLongLivedMCPProcesses(60) @@ -177,6 +180,9 @@ func collectRemediationHints(result *check.AuditResult, mcpProcs []check.MCPProc add("re-run `kas scaffold sync` to update config files with the current binary path, or reinstall service units") } } + if result.GitHooks != nil && !result.GitHooks.Configured { + add("run 'just hooks' to install the docs-drift pre-push hook") + } // Long-lived stdio mcp process hint. if len(mcpProcs) > 0 { @@ -208,6 +214,16 @@ func renderMCPEndpoint(cmd *cobra.Command, probeErr error) { fmt.Fprintf(out, " ✗ %s unreachable (%s)\n", mcpclient.SharedEndpointURL, probeErr) } +func renderGitHooks(cmd *cobra.Command, status *check.HookStatus) { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "\npre-push hook:\n") + if status.Configured { + fmt.Fprintf(out, " ✓ core.hooksPath=%s\n", status.ExpectedPath) + return + } + fmt.Fprintf(out, " ✗ pre-push hook not installed (core.hooksPath=%q)\n", status.ActualPath) +} + // renderBinaryPath prints a dedicated binary-path section before the health summary. func renderBinaryPath(cmd *cobra.Command, bp *check.BinaryPathResult) { out := cmd.OutOrStdout() diff --git a/cmd/kas/check_test.go b/cmd/kas/check_test.go index 7e6229775..56886008d 100644 --- a/cmd/kas/check_test.go +++ b/cmd/kas/check_test.go @@ -266,6 +266,45 @@ func TestCheckCmd_BinaryPathHealthyNoMismatch(t *testing.T) { assert.NotContains(t, out, "/nonexistent/stale/kas") } +func TestCheckCmd_PrePushHookHealthy(t *testing.T) { + prev := check.SetGitConfigFnForTest(func(string) (string, error) { return "scripts/git-hooks", nil }) + t.Cleanup(func() { check.SetGitConfigFnForTest(prev) }) + + out := captureCheckOutput(t, func(home, project string) { + require.NoError(t, os.MkdirAll(filepath.Join(project, "docs"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(project, "docs", "docs-drift-map.yml"), []byte("[]"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(project, "scripts", "git-hooks"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(project, "scripts", "git-hooks", "pre-push"), []byte("#!/usr/bin/env bash\n"), 0o755)) + }) + + assert.Contains(t, out, "pre-push hook:") + assert.Contains(t, out, "✓ core.hooksPath=scripts/git-hooks") +} + +func TestCheckCmd_PrePushHookMissing(t *testing.T) { + prev := check.SetGitConfigFnForTest(func(string) (string, error) { return "", nil }) + t.Cleanup(func() { check.SetGitConfigFnForTest(prev) }) + + out := captureCheckOutput(t, func(home, project string) { + require.NoError(t, os.MkdirAll(filepath.Join(project, "docs"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(project, "docs", "docs-drift-map.yml"), []byte("[]"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(project, "scripts", "git-hooks"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(project, "scripts", "git-hooks", "pre-push"), []byte("#!/usr/bin/env bash\n"), 0o755)) + }) + + assert.Contains(t, out, "pre-push hook not installed") + assert.Contains(t, out, "run 'just hooks' to install the docs-drift pre-push hook") +} + +func TestCheckCmd_PrePushHookSkippedOutsideKasmos(t *testing.T) { + prev := check.SetGitConfigFnForTest(func(string) (string, error) { return "", nil }) + t.Cleanup(func() { check.SetGitConfigFnForTest(prev) }) + + out := captureCheckOutput(t, nil) // no setup -> no docs-drift-map.yml + + assert.NotContains(t, out, "pre-push hook:") +} + // TestCheckCmd_ShowsCopyGlyph verifies that a non-symlink directory in a harness dir // shows the ≈ glyph. func TestCheckCmd_ShowsCopyGlyph(t *testing.T) { diff --git a/internal/check/check.go b/internal/check/check.go index 6d5001bee..37d71a235 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -68,6 +68,7 @@ type AuditResult struct { Project []ProjectSkillEntry InProject bool // whether cwd is a kas project BinaryPath *BinaryPathResult // always populated + GitHooks *HookStatus } // Audit runs all three audit layers and returns a complete result. @@ -96,6 +97,10 @@ func Audit(home, projectDir string, registry *harness.Registry) *AuditResult { // Binary path audit — always populated regardless of project detection. result.BinaryPath = AuditBinaryPaths(home, projectDir, runtime.GOOS) + gh := CheckPrePushHook(projectDir) + if !gh.Skipped { + result.GitHooks = &gh + } return result } @@ -138,5 +143,11 @@ func (r *AuditResult) Summary() (int, int) { ok += bpOK total += bpTotal } + if r.GitHooks != nil { + total++ + if r.GitHooks.Configured { + ok++ + } + } return ok, total } diff --git a/internal/check/githooks.go b/internal/check/githooks.go new file mode 100644 index 000000000..f9748daba --- /dev/null +++ b/internal/check/githooks.go @@ -0,0 +1,67 @@ +package check + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// HookStatus describes the pre-push hook installation state for a kasmos clone. +type HookStatus struct { + Skipped bool // true when cwd is not a kasmos clone (no docs-drift-map.yml) + Configured bool // core.hooksPath == ExpectedPath AND HookFileExists + ExpectedPath string // always "scripts/git-hooks" + ActualPath string // raw value of core.hooksPath ("" when unset) + HookFileExists bool // scripts/git-hooks/pre-push exists in repoRoot +} + +// gitConfigFn is the seam for reading core.hooksPath. Replaced in tests. +var gitConfigFn = func(repoRoot string) (string, error) { + cmd := exec.Command("git", "-C", repoRoot, "config", "--get", "core.hooksPath") + out, err := cmd.Output() + if err != nil { + // `git config --get` exits 1 when the key is unset; that is not an error. + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return "", nil + } + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// SetGitConfigFnForTest replaces the git-config seam with fn and returns the +// previous value so callers can restore it in t.Cleanup. For use in tests only. +func SetGitConfigFnForTest(fn func(string) (string, error)) func(string) (string, error) { + prev := gitConfigFn + gitConfigFn = fn + return prev +} + +// CheckPrePushHook inspects repoRoot to determine whether the docs-drift +// pre-push hook is installed and active. Returns Skipped=true when repoRoot +// is not a kasmos clone (used as the gating signal so non-kasmos cwds don't +// register a spurious unhealthy item). +func CheckPrePushHook(repoRoot string) HookStatus { + status := HookStatus{ExpectedPath: "scripts/git-hooks"} + + // Heuristic: kasmos clones contain docs/docs-drift-map.yml. + if _, err := os.Stat(filepath.Join(repoRoot, "docs", "docs-drift-map.yml")); err != nil { + status.Skipped = true + return status + } + + if _, err := os.Stat(filepath.Join(repoRoot, "scripts", "git-hooks", "pre-push")); err == nil { + status.HookFileExists = true + } + + actual, err := gitConfigFn(repoRoot) + if err != nil { + // Git unavailable or repoRoot not a git work tree - skip silently. + status.Skipped = true + return status + } + status.ActualPath = actual + status.Configured = (actual == status.ExpectedPath) && status.HookFileExists + return status +} diff --git a/internal/check/githooks_test.go b/internal/check/githooks_test.go new file mode 100644 index 000000000..99c6ef9c5 --- /dev/null +++ b/internal/check/githooks_test.go @@ -0,0 +1,56 @@ +package check + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckPrePushHook(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, dir string) + gitConfig string + gitConfigErr error + wantSkipped bool + wantConfigured bool + wantActual string + }{ + {name: "configured correctly", setup: setupKasmosLayout, gitConfig: "scripts/git-hooks", wantConfigured: true, wantActual: "scripts/git-hooks"}, + {name: "core.hooksPath unset", setup: setupKasmosLayout, gitConfig: "", wantConfigured: false, wantActual: ""}, + {name: "core.hooksPath custom", setup: setupKasmosLayout, gitConfig: ".husky", wantConfigured: false, wantActual: ".husky"}, + {name: "no docs-drift-map.yml", setup: func(t *testing.T, dir string) {}, gitConfig: "", wantSkipped: true}, + {name: "hook file missing", setup: setupKasmosLayoutNoHook, gitConfig: "scripts/git-hooks", wantConfigured: false, wantActual: "scripts/git-hooks"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + tc.setup(t, dir) + prev := SetGitConfigFnForTest(func(string) (string, error) { return tc.gitConfig, tc.gitConfigErr }) + t.Cleanup(func() { SetGitConfigFnForTest(prev) }) + + got := CheckPrePushHook(dir) + assert.Equal(t, tc.wantSkipped, got.Skipped) + if !tc.wantSkipped { + assert.Equal(t, tc.wantConfigured, got.Configured) + assert.Equal(t, tc.wantActual, got.ActualPath) + } + }) + } +} + +func setupKasmosLayout(t *testing.T, dir string) { + t.Helper() + setupKasmosLayoutNoHook(t, dir) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "scripts", "git-hooks"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "scripts", "git-hooks", "pre-push"), []byte("#!/usr/bin/env bash\n"), 0o755)) +} + +func setupKasmosLayoutNoHook(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "docs"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "docs", "docs-drift-map.yml"), []byte("[]"), 0o644)) +} From a556780faf5daa99fb4fcc7b7c0ffcd0aa423609 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 20:11:38 -0500 Subject: [PATCH 07/13] Fix docs drift hook smoke checkout --- .github/workflows/docs-drift.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docs-drift.yml b/.github/workflows/docs-drift.yml index 62357522b..203e567e4 100644 --- a/.github/workflows/docs-drift.yml +++ b/.github/workflows/docs-drift.yml @@ -100,6 +100,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Run hook unit scenarios run: bash scripts/git-hooks/test/run.sh From 6d640d2a9de5f952f30f2fecc64d6ec9cd75bdf2 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 20:19:37 -0500 Subject: [PATCH 08/13] fix: honor docs drift trailer in CI (master self-fix) --- .github/workflows/docs-drift.yml | 14 ++++++++++++++ scripts/git-hooks/README.md | 4 ++-- web/docs/docs/contributing/development-setup.mdx | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs-drift.yml b/.github/workflows/docs-drift.yml index 203e567e4..77340edc2 100644 --- a/.github/workflows/docs-drift.yml +++ b/.github/workflows/docs-drift.yml @@ -56,6 +56,20 @@ jobs: const report = JSON.parse(process.env.REPORT || '{"drift":[]}'); if (!report.drift.length) return; + const trailerPattern = /^Docs-Drift-Skip:\s*(.+)$/m; + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + const bypassCommit = commits.find((commit) => trailerPattern.test(commit.commit.message)); + if (bypassCommit) { + const trailer = bypassCommit.commit.message.match(trailerPattern)?.[0] || "Docs-Drift-Skip"; + core.notice(`docs drift bypassed via trailer (${trailer})`); + return; + } + const marker = ""; const body = `${marker} ## docs drift detected diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md index cca8bc74f..cd940af17 100644 --- a/scripts/git-hooks/README.md +++ b/scripts/git-hooks/README.md @@ -26,9 +26,9 @@ The hook iterates every line. Special cases: |-----|------| | `git push --no-verify` | one-off escape, skips all client hooks | | `KASMOS_SKIP_DOCS_DRIFT=1` env | scoped to docs-drift only | -| `Docs-Drift-Skip: ` commit trailer | auditable, preserved in history | +| `Docs-Drift-Skip: ` commit trailer | auditable, preserved in history, honored by CI | -CI runs the same check as a required status. Bypassing the hook does not bypass CI. +CI runs the same check as a required status. `--no-verify` and `KASMOS_SKIP_DOCS_DRIFT=1` do not bypass CI. ### dependencies diff --git a/web/docs/docs/contributing/development-setup.mdx b/web/docs/docs/contributing/development-setup.mdx index 0b8583886..0be181327 100644 --- a/web/docs/docs/contributing/development-setup.mdx +++ b/web/docs/docs/contributing/development-setup.mdx @@ -146,6 +146,6 @@ When the hook flags a false positive (typically a pure refactor), three escape h - `git push --no-verify` — skips all client-side hooks for this push. - `KASMOS_SKIP_DOCS_DRIFT=1 git push` — skips just the docs-drift check, leaves other hooks active. -- `Docs-Drift-Skip: ` commit trailer on any commit in the push range — auditable, preserved in history. +- `Docs-Drift-Skip: ` commit trailer on any commit in the push range — auditable, preserved in history, honored by CI. -CI runs the same check as a required status, so `--no-verify` lands you in a failing PR. Maintainers can override at branch protection level. +CI runs the same check as a required status, so `--no-verify` and `KASMOS_SKIP_DOCS_DRIFT=1` land you in a failing PR. Maintainers can override at branch protection level. From f6a35f010a07ec634a87321b8f0ed456cbf902c6 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 20:20:22 -0500 Subject: [PATCH 09/13] [kas] implementation of 'pre-push-hook-docs-drift' --- opencode.jsonc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/opencode.jsonc b/opencode.jsonc index 5428270dd..9f75aeadb 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -2,7 +2,7 @@ "$schema": "https://opencode.ai/config.json", "agent": { "architect": { - "model": "gpt-5.5", + "model": "anthropic/claude-opus-4-7", "permission": { "bash": "allow", "edit": { @@ -85,12 +85,12 @@ "read": "allow", "write": "allow" }, - "reasoningEffort": "medium", + "reasoningEffort": "low", "temperature": 0.1, "textVerbosity": "low" }, "fixer": { - "model": "anthropic/claude-opus-4-7", + "model": "gpt-5.5", "permission": { "bash": "allow", "edit": "allow", @@ -109,7 +109,7 @@ "write": "allow" }, "reasoningEffort": "high", - "temperature": 0.2, + "temperature": 0.1, "textVerbosity": "low" }, "master": { @@ -169,7 +169,7 @@ }, "planner_gpt": { "model": "gpt-5.5", - "reasoningEffort": "high", + "reasoningEffort": "xhigh", "temperature": 0.3 }, "planner_opus": { @@ -178,7 +178,7 @@ "temperature": 0.3 }, "reviewer": { - "model": "gpt-5.5", + "model": "anthropic/claude-sonnet-4-6", "permission": { "bash": "allow", "edit": { From 40ca98ca9bfb1fdd26b4ab50b2515c52c01df759 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 21:03:03 -0500 Subject: [PATCH 10/13] fix docs drift hook target ref --- scripts/detect-docs-drift.sh | 8 +++++++- scripts/git-hooks/pre-push | 2 +- scripts/git-hooks/test/run.sh | 21 +++++++++++++++++++-- scripts/git-hooks/test/smoke.sh | 5 +++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/detect-docs-drift.sh b/scripts/detect-docs-drift.sh index 03d0e77fe..99dc10185 100755 --- a/scripts/detect-docs-drift.sh +++ b/scripts/detect-docs-drift.sh @@ -3,6 +3,7 @@ set -euo pipefail MAP="${1:-docs/docs-drift-map.yml}" BASE_REF="${BASE_REF:-$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo main)}" +TARGET_REF="${TARGET_REF:-HEAD}" if ! git rev-parse --verify "$BASE_REF^{commit}" >/dev/null 2>&1; then if git rev-parse --verify "origin/$BASE_REF^{commit}" >/dev/null 2>&1; then @@ -10,13 +11,18 @@ if ! git rev-parse --verify "$BASE_REF^{commit}" >/dev/null 2>&1; then fi fi +if ! git rev-parse --verify "$TARGET_REF^{commit}" >/dev/null 2>&1; then + echo "target ref not found: $TARGET_REF" >&2 + exit 2 +fi + join_by_comma() { local IFS=, printf '%s' "$*" } changed_paths() { - git diff --name-only "$BASE_REF"...HEAD -- "$@" 2>/dev/null || true + git diff --name-only "$BASE_REF"..."$TARGET_REF" -- "$@" 2>/dev/null || true } echo '{"drift": [' diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index b674664cb..ae4e0bde0 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -95,7 +95,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do continue fi - REPORT="$(BASE_REF="$base" bash scripts/detect-docs-drift.sh)" + REPORT="$(BASE_REF="$base" TARGET_REF="$local_sha" bash scripts/detect-docs-drift.sh)" if [ "$(jq '.drift | length' <<<"$REPORT")" != "0" ]; then print_docs_drift_rejection "$REPORT" exit 1 diff --git a/scripts/git-hooks/test/run.sh b/scripts/git-hooks/test/run.sh index 110a6fa0b..9689f8069 100755 --- a/scripts/git-hooks/test/run.sh +++ b/scripts/git-hooks/test/run.sh @@ -103,7 +103,9 @@ assert_status() { assert_stderr_contains() { local expected="$1" - if ! rg -q --fixed-strings "$expected" "$SCENARIO_STDERR"; then + local stderr + stderr="$(cat "$SCENARIO_STDERR")" + if [[ "$stderr" != *"$expected"* ]]; then fail "stderr missing '$expected'; got: $(tr '\n' ' ' <"$SCENARIO_STDERR")" return 1 fi @@ -199,6 +201,20 @@ scenario_new_branch() { assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" } +scenario_non_head_push_ref() { + local repo remote feature_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + remote="$(git -C "$repo" rev-parse HEAD)" + with_feature_branch "$repo" + make_drift_commit "$repo" + feature_sha="$(git -C "$repo" rev-parse HEAD)" + git -C "$repo" switch -q main + run_hook "$repo" "refs/heads/feature $feature_sha refs/heads/feature $remote" env + assert_status 1 || return 1 + assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" +} + scenario_bypass_env() { local repo remote local_sha repo="$(seed_repo)" || return 1 @@ -277,7 +293,7 @@ run_scenario() { main() { local passed=0 - local total=8 + local total=9 local scenarios=( "drift_detected:scenario_drift_detected" @@ -285,6 +301,7 @@ main() { "deletion_ref:scenario_deletion_ref" "tag_push:scenario_tag_push" "new_branch:scenario_new_branch" + "non_head_push_ref:scenario_non_head_push_ref" "bypass_env:scenario_bypass_env" "bypass_trailer:scenario_bypass_trailer" "missing_yq:scenario_missing_yq" diff --git a/scripts/git-hooks/test/smoke.sh b/scripts/git-hooks/test/smoke.sh index bd7a283e8..520133f97 100755 --- a/scripts/git-hooks/test/smoke.sh +++ b/scripts/git-hooks/test/smoke.sh @@ -64,7 +64,7 @@ hook_status=0 drift_count="$( cd "$ROOT" - BASE_REF="$base_sha" bash "$DETECTOR" | jq '.drift | length' + BASE_REF="$base_sha" TARGET_REF="$head_sha" bash "$DETECTOR" | jq '.drift | length' )" if [ "$drift_count" -eq 0 ]; then @@ -83,7 +83,8 @@ if [ "$hook_status" -eq 0 ]; then exit 1 fi -if ! rg -q --fixed-strings "docs-drift: push blocked" "$stderr_file"; then +stderr_contents="$(cat "$stderr_file")" +if [[ "$stderr_contents" != *"docs-drift: push blocked"* ]]; then echo "expected hook stderr to contain docs-drift block message" >&2 tr '\n' ' ' <"$stderr_file" >&2 echo >&2 From 6aeb4dd42f4fa083e714095d1e45b3bf17abea2f Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 21:29:13 -0500 Subject: [PATCH 11/13] address docs drift review findings --- .github/workflows/docs-drift.yml | 1 + cmd/kas/check.go | 12 ++- cmd/kas/check_test.go | 18 +++++ internal/check/githooks.go | 19 ++++- internal/check/githooks_test.go | 80 ++++++++++++++++--- scripts/git-hooks/README.md | 2 +- scripts/git-hooks/install.sh | 26 +++++- .../docs/contributing/development-setup.mdx | 2 +- 8 files changed, 143 insertions(+), 17 deletions(-) diff --git a/.github/workflows/docs-drift.yml b/.github/workflows/docs-drift.yml index 77340edc2..413da5302 100644 --- a/.github/workflows/docs-drift.yml +++ b/.github/workflows/docs-drift.yml @@ -12,6 +12,7 @@ on: workflow_dispatch: permissions: + contents: read pull-requests: write jobs: diff --git a/cmd/kas/check.go b/cmd/kas/check.go index 0d0269bbe..2f7d0507c 100644 --- a/cmd/kas/check.go +++ b/cmd/kas/check.go @@ -218,12 +218,22 @@ func renderGitHooks(cmd *cobra.Command, status *check.HookStatus) { out := cmd.OutOrStdout() fmt.Fprintf(out, "\npre-push hook:\n") if status.Configured { - fmt.Fprintf(out, " ✓ core.hooksPath=%s\n", status.ExpectedPath) + fmt.Fprintf(out, " ✓ core.hooksPath=%s\n", configuredHooksPathDisplay(status)) return } fmt.Fprintf(out, " ✗ pre-push hook not installed (core.hooksPath=%q)\n", status.ActualPath) } +func configuredHooksPathDisplay(status *check.HookStatus) string { + if status != nil && status.ActualPath != "" { + return status.ActualPath + } + if status != nil { + return status.ExpectedPath + } + return "" +} + // renderBinaryPath prints a dedicated binary-path section before the health summary. func renderBinaryPath(cmd *cobra.Command, bp *check.BinaryPathResult) { out := cmd.OutOrStdout() diff --git a/cmd/kas/check_test.go b/cmd/kas/check_test.go index 56886008d..5b9ce037d 100644 --- a/cmd/kas/check_test.go +++ b/cmd/kas/check_test.go @@ -281,6 +281,24 @@ func TestCheckCmd_PrePushHookHealthy(t *testing.T) { assert.Contains(t, out, "✓ core.hooksPath=scripts/git-hooks") } +func TestCheckCmd_PrePushHookHealthyAbsolutePath(t *testing.T) { + var hookPath string + prev := check.SetGitConfigFnForTest(func(string) (string, error) { return hookPath, nil }) + t.Cleanup(func() { check.SetGitConfigFnForTest(prev) }) + + out := captureCheckOutput(t, func(home, project string) { + hookPath = filepath.Join(project, "scripts", "git-hooks") + require.NoError(t, os.MkdirAll(filepath.Join(project, "docs"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(project, "docs", "docs-drift-map.yml"), []byte("[]"), 0o644)) + require.NoError(t, os.MkdirAll(hookPath, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(hookPath, "pre-push"), []byte("#!/usr/bin/env bash\n"), 0o755)) + }) + + assert.Contains(t, out, "pre-push hook:") + assert.Contains(t, out, "✓ core.hooksPath="+hookPath) + assert.NotContains(t, out, "pre-push hook not installed") +} + func TestCheckCmd_PrePushHookMissing(t *testing.T) { prev := check.SetGitConfigFnForTest(func(string) (string, error) { return "", nil }) t.Cleanup(func() { check.SetGitConfigFnForTest(prev) }) diff --git a/internal/check/githooks.go b/internal/check/githooks.go index f9748daba..9fd82397a 100644 --- a/internal/check/githooks.go +++ b/internal/check/githooks.go @@ -10,8 +10,8 @@ import ( // HookStatus describes the pre-push hook installation state for a kasmos clone. type HookStatus struct { Skipped bool // true when cwd is not a kasmos clone (no docs-drift-map.yml) - Configured bool // core.hooksPath == ExpectedPath AND HookFileExists - ExpectedPath string // always "scripts/git-hooks" + Configured bool // core.hooksPath points at ExpectedPath AND HookFileExists + ExpectedPath string // canonical relative hook path, "scripts/git-hooks" ActualPath string // raw value of core.hooksPath ("" when unset) HookFileExists bool // scripts/git-hooks/pre-push exists in repoRoot } @@ -62,6 +62,19 @@ func CheckPrePushHook(repoRoot string) HookStatus { return status } status.ActualPath = actual - status.Configured = (actual == status.ExpectedPath) && status.HookFileExists + status.Configured = hooksPathMatches(repoRoot, actual, status.ExpectedPath) && status.HookFileExists return status } + +func hooksPathMatches(repoRoot, actual, expected string) bool { + actual = strings.TrimSpace(actual) + if actual == "" { + return false + } + expected = filepath.Clean(expected) + actual = filepath.Clean(actual) + if !filepath.IsAbs(actual) { + return actual == expected + } + return actual == filepath.Clean(filepath.Join(repoRoot, expected)) +} diff --git a/internal/check/githooks_test.go b/internal/check/githooks_test.go index 99c6ef9c5..a779b1da1 100644 --- a/internal/check/githooks_test.go +++ b/internal/check/githooks_test.go @@ -13,35 +13,97 @@ func TestCheckPrePushHook(t *testing.T) { cases := []struct { name string setup func(t *testing.T, dir string) - gitConfig string + gitConfig func(t *testing.T, dir string) string gitConfigErr error wantSkipped bool wantConfigured bool - wantActual string + wantActual func(t *testing.T, dir string) string }{ - {name: "configured correctly", setup: setupKasmosLayout, gitConfig: "scripts/git-hooks", wantConfigured: true, wantActual: "scripts/git-hooks"}, - {name: "core.hooksPath unset", setup: setupKasmosLayout, gitConfig: "", wantConfigured: false, wantActual: ""}, - {name: "core.hooksPath custom", setup: setupKasmosLayout, gitConfig: ".husky", wantConfigured: false, wantActual: ".husky"}, - {name: "no docs-drift-map.yml", setup: func(t *testing.T, dir string) {}, gitConfig: "", wantSkipped: true}, - {name: "hook file missing", setup: setupKasmosLayoutNoHook, gitConfig: "scripts/git-hooks", wantConfigured: false, wantActual: "scripts/git-hooks"}, + { + name: "configured correctly", + setup: setupKasmosLayout, + gitConfig: literalHookConfig("scripts/git-hooks"), + wantConfigured: true, + wantActual: literalHookConfig("scripts/git-hooks"), + }, + { + name: "configured with dot relative path", + setup: setupKasmosLayout, + gitConfig: literalHookConfig("./scripts/git-hooks/"), + wantConfigured: true, + wantActual: literalHookConfig("./scripts/git-hooks/"), + }, + { + name: "configured with absolute path", + setup: setupKasmosLayout, + gitConfig: repoHookConfig, + wantConfigured: true, + wantActual: repoHookConfig, + }, + { + name: "absolute path outside repo", + setup: setupKasmosLayout, + gitConfig: func(t *testing.T, dir string) string { return filepath.Join(t.TempDir(), "scripts", "git-hooks") }, + wantConfigured: false, + }, + { + name: "core.hooksPath unset", + setup: setupKasmosLayout, + gitConfig: literalHookConfig(""), + wantConfigured: false, + wantActual: literalHookConfig(""), + }, + { + name: "core.hooksPath custom", + setup: setupKasmosLayout, + gitConfig: literalHookConfig(".husky"), + wantConfigured: false, + wantActual: literalHookConfig(".husky"), + }, + { + name: "no docs-drift-map.yml", + setup: func(t *testing.T, dir string) {}, + gitConfig: literalHookConfig(""), + wantSkipped: true, + }, + { + name: "hook file missing", + setup: setupKasmosLayoutNoHook, + gitConfig: literalHookConfig("scripts/git-hooks"), + wantConfigured: false, + wantActual: literalHookConfig("scripts/git-hooks"), + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() tc.setup(t, dir) - prev := SetGitConfigFnForTest(func(string) (string, error) { return tc.gitConfig, tc.gitConfigErr }) + gitConfig := tc.gitConfig(t, dir) + prev := SetGitConfigFnForTest(func(string) (string, error) { return gitConfig, tc.gitConfigErr }) t.Cleanup(func() { SetGitConfigFnForTest(prev) }) got := CheckPrePushHook(dir) assert.Equal(t, tc.wantSkipped, got.Skipped) if !tc.wantSkipped { + wantActual := gitConfig + if tc.wantActual != nil { + wantActual = tc.wantActual(t, dir) + } assert.Equal(t, tc.wantConfigured, got.Configured) - assert.Equal(t, tc.wantActual, got.ActualPath) + assert.Equal(t, wantActual, got.ActualPath) } }) } } +func literalHookConfig(value string) func(t *testing.T, dir string) string { + return func(t *testing.T, dir string) string { return value } +} + +func repoHookConfig(t *testing.T, dir string) string { + return filepath.Join(dir, "scripts", "git-hooks") +} + func setupKasmosLayout(t *testing.T, dir string) { t.Helper() setupKasmosLayoutNoHook(t, dir) diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md index cd940af17..d82ed3567 100644 --- a/scripts/git-hooks/README.md +++ b/scripts/git-hooks/README.md @@ -1,6 +1,6 @@ # kasmos git hooks -This directory holds the checked-in client-side git hooks for kasmos contributors. Install with `just hooks` (sets `git config core.hooksPath`). +This directory holds the checked-in client-side git hooks for kasmos contributors. Install with `just hooks` (sets `git config core.hooksPath`). `kas check` treats both `scripts/git-hooks` and an absolute path to this directory as configured. ## pre-push diff --git a/scripts/git-hooks/install.sh b/scripts/git-hooks/install.sh index 5378bf7f9..27fac2198 100755 --- a/scripts/git-hooks/install.sh +++ b/scripts/git-hooks/install.sh @@ -12,8 +12,30 @@ if [ ! -f "$repo_root/docs/docs-drift-map.yml" ]; then exit 1 fi +is_kasmos_hooks_path() { + local path="$1" + local expected + local configured + + if [ -z "$path" ]; then + return 1 + fi + + expected="$(cd "$repo_root/scripts/git-hooks" && pwd -P)" + case "$path" in + /*) + configured="$(cd "$path" 2>/dev/null && pwd -P)" || return 1 + ;; + *) + configured="$(cd "$repo_root/$path" 2>/dev/null && pwd -P)" || return 1 + ;; + esac + + [ "$configured" = "$expected" ] +} + current="$(git config --get core.hooksPath || true)" -if [ -n "$current" ] && [ "$current" != "scripts/git-hooks" ] && [ "$force" -ne 1 ]; then +if [ -n "$current" ] && ! is_kasmos_hooks_path "$current" && [ "$force" -ne 1 ]; then echo "error: core.hooksPath is already set to '$current'." >&2 echo "refusing to overwrite. re-run with --force, or unset manually:" >&2 echo " git config --unset core.hooksPath" >&2 @@ -22,6 +44,6 @@ fi git config core.hooksPath scripts/git-hooks chmod +x scripts/git-hooks/pre-push -git fetch origin "${KASMOS_DEFAULT_BRANCH:-main}" --quiet || true +git fetch origin "${KASMOS_DEFAULT_BRANCH:-main}" --quiet >/dev/null 2>&1 || true echo "installed: core.hooksPath=scripts/git-hooks" diff --git a/web/docs/docs/contributing/development-setup.mdx b/web/docs/docs/contributing/development-setup.mdx index 0be181327..2fe33f2c4 100644 --- a/web/docs/docs/contributing/development-setup.mdx +++ b/web/docs/docs/contributing/development-setup.mdx @@ -138,7 +138,7 @@ just hooks bash scripts/git-hooks/install.sh ``` -This sets `git config core.hooksPath scripts/git-hooks`. If you already use a custom `core.hooksPath` (e.g. husky, lefthook), the installer refuses to overwrite — pass `--force` to override. +This sets `git config core.hooksPath scripts/git-hooks`. An existing relative or absolute path to `scripts/git-hooks` is treated as already configured. If you already use a different custom `core.hooksPath` (e.g. husky, lefthook), the installer refuses to overwrite — pass `--force` to override. ### bypass options From 455cbf4ec50ae7d090c0015eeba0b2d5d8e5fa78 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 22:08:23 -0500 Subject: [PATCH 12/13] fix pre-push remote base resolution --- scripts/git-hooks/README.md | 2 +- scripts/git-hooks/pre-push | 22 ++++++++++++++- scripts/git-hooks/test/run.sh | 47 ++++++++++++++++++++++++++++++--- scripts/git-hooks/test/smoke.sh | 26 +++++++++++++++--- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md index d82ed3567..055a249ba 100644 --- a/scripts/git-hooks/README.md +++ b/scripts/git-hooks/README.md @@ -18,7 +18,7 @@ The hook iterates every line. Special cases: - `` is the all-zero sha → ref deletion, allow. - `` matches `refs/tags/*` → tag push, allow. -- `` is the all-zero sha → new branch, compare against `origin/${KASMOS_DEFAULT_BRANCH:-main}`. +- `` is the all-zero sha → new branch, compare against the pushed remote's default branch, or `${KASMOS_DEFAULT_BRANCH}` when set. ### bypass diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index ae4e0bde0..c0f656f91 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -2,6 +2,7 @@ set -euo pipefail ZERO_SHA="0000000000000000000000000000000000000000" +remote_name="${1:-origin}" repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" if [ -z "$repo_root" ]; then @@ -52,6 +53,25 @@ print_docs_drift_rejection() { } >&2 } +remote_default_ref() { + local remote="$1" + local configured_default="${KASMOS_DEFAULT_BRANCH:-}" + local remote_head + + if [ -n "$configured_default" ]; then + printf '%s/%s\n' "$remote" "$configured_default" + return + fi + + remote_head="$(git symbolic-ref --quiet --short "refs/remotes/$remote/HEAD" 2>/dev/null || true)" + if [ -n "$remote_head" ]; then + printf '%s\n' "$remote_head" + return + fi + + printf '%s/main\n' "$remote" +} + while read -r local_ref local_sha remote_ref remote_sha; do if [ -z "${local_ref:-}" ]; then continue @@ -73,7 +93,7 @@ while read -r local_ref local_sha remote_ref remote_sha; do fi if [ "$remote_sha" = "$ZERO_SHA" ]; then - default_branch="origin/${KASMOS_DEFAULT_BRANCH:-main}" + default_branch="$(remote_default_ref "$remote_name")" base="$(git merge-base "$local_sha" "$default_branch" 2>/dev/null || true)" if [ -z "$base" ]; then echo "docs-drift: warning: unable to find merge-base for $local_ref and $default_branch; skipping docs drift check" >&2 diff --git a/scripts/git-hooks/test/run.sh b/scripts/git-hooks/test/run.sh index 9689f8069..011fc400c 100755 --- a/scripts/git-hooks/test/run.sh +++ b/scripts/git-hooks/test/run.sh @@ -11,6 +11,7 @@ SCENARIO_TMP="" SCENARIO_STDERR="" SCENARIO_STATUS=0 SCENARIO_PATH="" +SCENARIO_REMOTE_NAME="origin" fail() { printf '%s\n' "$1" @@ -56,12 +57,32 @@ set -euo pipefail if [ "${1:-}" = "e" ] && [ "${2:-}" = "-o=json" ] && [ "${3:-}" = "-I=0" ] && [ "${4:-}" = ".[]" ]; then python3 - "$5" <<'PY' +import ast import json import sys -import yaml +data = [] +current = None with open(sys.argv[1], "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) + for raw in fh: + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("- "): + if current is not None: + data.append(current) + current = {} + line = line[2:].strip() + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + if current is None: + current = {} + current[key] = ast.literal_eval(value) if value else [] +if current is not None: + data.append(current) for entry in data: print(json.dumps(entry, separators=(",", ":"))) PY @@ -84,12 +105,13 @@ run_hook() { local stdin_line="$2" shift 2 local hook_path="${SCENARIO_PATH:-$repo/test-bin:$PATH}" + local remote_name="${SCENARIO_REMOTE_NAME:-origin}" SCENARIO_STDERR="$repo/stderr.txt" SCENARIO_STATUS=0 ( cd "$repo" - "$@" PATH="$hook_path" bash scripts/git-hooks/pre-push >/dev/null 2>"$SCENARIO_STDERR" <<<"$stdin_line" + "$@" PATH="$hook_path" bash scripts/git-hooks/pre-push "$remote_name" "git@example.test:$remote_name/repo.git" >/dev/null 2>"$SCENARIO_STDERR" <<<"$stdin_line" ) || SCENARIO_STATUS=$? } @@ -201,6 +223,21 @@ scenario_new_branch() { assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" } +scenario_new_branch_uses_push_remote() { + local repo base local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + base="$(git -C "$repo" rev-parse HEAD)" + git -C "$repo" update-ref refs/remotes/upstream/main "$base" + SCENARIO_REMOTE_NAME="upstream" + with_feature_branch "$repo" + make_drift_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $ZERO_SHA" env + assert_status 1 || return 1 + assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" +} + scenario_non_head_push_ref() { local repo remote feature_sha repo="$(seed_repo)" || return 1 @@ -278,6 +315,7 @@ run_scenario() { SCENARIO_STDERR="" SCENARIO_STATUS=0 SCENARIO_PATH="" + SCENARIO_REMOTE_NAME="origin" local message if message="$($fn 2>&1)"; then @@ -293,7 +331,7 @@ run_scenario() { main() { local passed=0 - local total=9 + local total=10 local scenarios=( "drift_detected:scenario_drift_detected" @@ -301,6 +339,7 @@ main() { "deletion_ref:scenario_deletion_ref" "tag_push:scenario_tag_push" "new_branch:scenario_new_branch" + "new_branch_uses_push_remote:scenario_new_branch_uses_push_remote" "non_head_push_ref:scenario_non_head_push_ref" "bypass_env:scenario_bypass_env" "bypass_trailer:scenario_bypass_trailer" diff --git a/scripts/git-hooks/test/smoke.sh b/scripts/git-hooks/test/smoke.sh index 520133f97..8d1118b1d 100755 --- a/scripts/git-hooks/test/smoke.sh +++ b/scripts/git-hooks/test/smoke.sh @@ -29,12 +29,32 @@ set -euo pipefail if [ "${1:-}" = "e" ] && [ "${2:-}" = "-o=json" ] && [ "${3:-}" = "-I=0" ] && [ "${4:-}" = ".[]" ]; then python3 - "$5" <<'PY' +import ast import json import sys -import yaml +data = [] +current = None with open(sys.argv[1], "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) + for raw in fh: + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("- "): + if current is not None: + data.append(current) + current = {} + line = line[2:].strip() + if ":" not in line: + continue + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + if current is None: + current = {} + current[key] = ast.literal_eval(value) if value else [] +if current is not None: + data.append(current) for entry in data: print(json.dumps(entry, separators=(",", ":"))) PY @@ -59,7 +79,7 @@ ensure_detector_yq hook_status=0 ( cd "$ROOT" - bash "$HOOK" >/dev/null 2>"$stderr_file" <<<"$stdin_line" + bash "$HOOK" origin git@example.test:kastheco/kasmos.git >/dev/null 2>"$stderr_file" <<<"$stdin_line" ) || hook_status=$? drift_count="$( From 8be6173ac55b9d99115fba1649b6cdc40e8bcd98 Mon Sep 17 00:00:00 2001 From: kas Date: Thu, 30 Apr 2026 22:28:44 -0500 Subject: [PATCH 13/13] harden docs drift hook edge cases --- .github/workflows/docs-drift.yml | 4 +++ scripts/git-hooks/README.md | 4 +-- scripts/git-hooks/install.sh | 2 +- scripts/git-hooks/pre-push | 24 +++++++++++--- scripts/git-hooks/test/run.sh | 57 +++++++++++++++++++++++++++++++- 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs-drift.yml b/.github/workflows/docs-drift.yml index 413da5302..fbab43581 100644 --- a/.github/workflows/docs-drift.yml +++ b/.github/workflows/docs-drift.yml @@ -8,7 +8,11 @@ on: - 'daemon/**.go' - 'internal/**.go' - 'orchestration/**.go' + - 'docs/docs-drift-map.yml' + - 'scripts/detect-docs-drift.sh' + - 'scripts/git-hooks/**' - 'web/docs/docs/**' + - '.github/workflows/docs-drift.yml' workflow_dispatch: permissions: diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md index 055a249ba..fc9a23716 100644 --- a/scripts/git-hooks/README.md +++ b/scripts/git-hooks/README.md @@ -18,7 +18,7 @@ The hook iterates every line. Special cases: - `` is the all-zero sha → ref deletion, allow. - `` matches `refs/tags/*` → tag push, allow. -- `` is the all-zero sha → new branch, compare against the pushed remote's default branch, or `${KASMOS_DEFAULT_BRANCH}` when set. +- `` is the all-zero sha → new branch, compare against the pushed remote's default branch, or `${KASMOS_DEFAULT_BRANCH}` when set. If the local remote-tracking ref is missing, the hook fetches that branch; if it still cannot find a merge base, the push is blocked. ### bypass @@ -36,7 +36,7 @@ CI runs the same check as a required status. `--no-verify` and `KASMOS_SKIP_DOCS ### tests -- `scripts/git-hooks/test/run.sh` — synthetic-repo unit scenarios (8 cases, hermetic). +- `scripts/git-hooks/test/run.sh` — synthetic-repo unit scenarios. - `scripts/git-hooks/test/smoke.sh` — runs hook against the real repo HEAD and asserts agreement with the detector. Both are invoked from `.github/workflows/docs-drift.yml`. diff --git a/scripts/git-hooks/install.sh b/scripts/git-hooks/install.sh index 27fac2198..2a0096379 100755 --- a/scripts/git-hooks/install.sh +++ b/scripts/git-hooks/install.sh @@ -43,7 +43,7 @@ if [ -n "$current" ] && ! is_kasmos_hooks_path "$current" && [ "$force" -ne 1 ]; fi git config core.hooksPath scripts/git-hooks -chmod +x scripts/git-hooks/pre-push +chmod +x "$repo_root/scripts/git-hooks/pre-push" git fetch origin "${KASMOS_DEFAULT_BRANCH:-main}" --quiet >/dev/null 2>&1 || true echo "installed: core.hooksPath=scripts/git-hooks" diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push index c0f656f91..11b077621 100755 --- a/scripts/git-hooks/pre-push +++ b/scripts/git-hooks/pre-push @@ -54,22 +54,30 @@ print_docs_drift_rejection() { } remote_default_ref() { + local remote="$1" + local branch + + branch="$(remote_default_branch "$remote")" + printf '%s/%s\n' "$remote" "$branch" +} + +remote_default_branch() { local remote="$1" local configured_default="${KASMOS_DEFAULT_BRANCH:-}" local remote_head if [ -n "$configured_default" ]; then - printf '%s/%s\n' "$remote" "$configured_default" + printf '%s\n' "$configured_default" return fi remote_head="$(git symbolic-ref --quiet --short "refs/remotes/$remote/HEAD" 2>/dev/null || true)" if [ -n "$remote_head" ]; then - printf '%s\n' "$remote_head" + printf '%s\n' "${remote_head#"$remote/"}" return fi - printf '%s/main\n' "$remote" + printf 'main\n' } while read -r local_ref local_sha remote_ref remote_sha; do @@ -93,11 +101,17 @@ while read -r local_ref local_sha remote_ref remote_sha; do fi if [ "$remote_sha" = "$ZERO_SHA" ]; then + default_branch_name="$(remote_default_branch "$remote_name")" default_branch="$(remote_default_ref "$remote_name")" base="$(git merge-base "$local_sha" "$default_branch" 2>/dev/null || true)" if [ -z "$base" ]; then - echo "docs-drift: warning: unable to find merge-base for $local_ref and $default_branch; skipping docs drift check" >&2 - continue + if git fetch --quiet "$remote_name" "$default_branch_name" 2>/dev/null; then + base="$(git merge-base "$local_sha" FETCH_HEAD 2>/dev/null || true)" + fi + fi + if [ -z "$base" ]; then + echo "docs-drift: unable to find merge-base for $local_ref and $default_branch; refusing to skip docs drift check" >&2 + exit 1 fi else base="$remote_sha" diff --git a/scripts/git-hooks/test/run.sh b/scripts/git-hooks/test/run.sh index 011fc400c..a74382a9f 100755 --- a/scripts/git-hooks/test/run.sh +++ b/scripts/git-hooks/test/run.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" HOOK_SRC="$ROOT/scripts/git-hooks/pre-push" +INSTALL_SRC="$ROOT/scripts/git-hooks/install.sh" DETECTOR_SRC="$ROOT/scripts/detect-docs-drift.sh" MAP_SRC="$ROOT/docs/docs-drift-map.yml" ZERO_SHA="0000000000000000000000000000000000000000" @@ -40,6 +41,8 @@ seed_repo() { fi cp "$HOOK_SRC" "$repo/scripts/git-hooks/pre-push" chmod +x "$repo/scripts/git-hooks/pre-push" + cp "$INSTALL_SRC" "$repo/scripts/git-hooks/install.sh" + chmod +x "$repo/scripts/git-hooks/install.sh" printf 'package main\n' >"$repo/cmd/task.go" printf '# task docs\n' >"$repo/web/docs/docs/cli-reference/task.mdx" @@ -238,6 +241,36 @@ scenario_new_branch_uses_push_remote() { assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" } +scenario_new_branch_fetches_missing_remote_base() { + local repo remote_repo local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + remote_repo="$repo/remote.git" + git init --bare -q --initial-branch=main "$remote_repo" + git -C "$repo" remote add upstream "$remote_repo" + git -C "$repo" push -q upstream main + SCENARIO_REMOTE_NAME="upstream" + with_feature_branch "$repo" + make_drift_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $ZERO_SHA" env + assert_status 1 || return 1 + assert_stderr_contains "web/docs/docs/cli-reference/task.mdx" +} + +scenario_new_branch_fails_when_base_unavailable() { + local repo local_sha + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + SCENARIO_REMOTE_NAME="missing-remote" + with_feature_branch "$repo" + make_drift_commit "$repo" + local_sha="$(git -C "$repo" rev-parse HEAD)" + run_hook "$repo" "refs/heads/feature $local_sha refs/heads/feature $ZERO_SHA" env + assert_status 1 || return 1 + assert_stderr_contains "refusing to skip docs drift check" +} + scenario_non_head_push_ref() { local repo remote feature_sha repo="$(seed_repo)" || return 1 @@ -308,6 +341,25 @@ scenario_missing_yq() { assert_stderr_contains "yq and jq required" } +scenario_install_from_subdirectory() { + local repo + repo="$(seed_repo)" || return 1 + SCENARIO_TMP="$repo" + mkdir -p "$repo/nested/dir" + ( + cd "$repo/nested/dir" + bash "$repo/scripts/git-hooks/install.sh" >/dev/null + ) + if [ "$(git -C "$repo" config --get core.hooksPath)" != "scripts/git-hooks" ]; then + fail "installer did not configure core.hooksPath" + return 1 + fi + if [ ! -x "$repo/scripts/git-hooks/pre-push" ]; then + fail "installer did not chmod repo-root pre-push hook" + return 1 + fi +} + run_scenario() { local name="$1" local fn="$2" @@ -331,7 +383,7 @@ run_scenario() { main() { local passed=0 - local total=10 + local total=13 local scenarios=( "drift_detected:scenario_drift_detected" @@ -340,10 +392,13 @@ main() { "tag_push:scenario_tag_push" "new_branch:scenario_new_branch" "new_branch_uses_push_remote:scenario_new_branch_uses_push_remote" + "new_branch_fetches_missing_remote_base:scenario_new_branch_fetches_missing_remote_base" + "new_branch_fails_when_base_unavailable:scenario_new_branch_fails_when_base_unavailable" "non_head_push_ref:scenario_non_head_push_ref" "bypass_env:scenario_bypass_env" "bypass_trailer:scenario_bypass_trailer" "missing_yq:scenario_missing_yq" + "install_from_subdirectory:scenario_install_from_subdirectory" ) for scenario in "${scenarios[@]}"; do