diff --git a/docs/repo-maintenance.md b/docs/repo-maintenance.md index 7ea363e..d2d042c 100644 --- a/docs/repo-maintenance.md +++ b/docs/repo-maintenance.md @@ -43,11 +43,22 @@ just repos::hygiene -- -R org/repo ### Where to run it +Run `just repos::hygiene` and `just repos::hygiene-prune` **from inside** the +checkout you want to audit. The recipes use your invocation directory (where you +ran `just`), not the dotfiles justfile location. + - Preferred: canonical repo checkouts (git repo or jj colocated repo). - Supported: git worktrees and jj workspaces. - In git worktrees, local branch deletion uses `git branch -d` and skips branches checked out elsewhere or not fully merged. +From a project-task workspace checkout: + +```bash +cd ~/dev/projects//// +just repos::hygiene +``` + ### Classification states | State | Prune | Review patch | diff --git a/dot_agents/skills/bookmark-pr-hygiene/SKILL.md b/dot_agents/skills/bookmark-pr-hygiene/SKILL.md index 5cfc84a..10aacd2 100644 --- a/dot_agents/skills/bookmark-pr-hygiene/SKILL.md +++ b/dot_agents/skills/bookmark-pr-hygiene/SKILL.md @@ -50,6 +50,8 @@ Optional repo override: ## Safety notes +- Run `just repos::hygiene` from inside the checkout you want to audit; the + recipe uses your invocation directory, not the dotfiles justfile location. - Prefer running from a canonical checkout (`git` repo or jj colocated repo). - Running from git worktrees or jj workspaces is supported, but canonical repo context is less ambiguous. diff --git a/repos.just b/repos.just index c0e49d1..82dd881 100644 --- a/repos.just +++ b/repos.just @@ -7,26 +7,28 @@ default: hygiene *args: @set -euo pipefail; \ _jd="{{ justfile_directory() }}"; \ + _inv="{{ invocation_directory() }}"; \ script="$_jd/scripts/bookmark-pr-hygiene.sh"; \ if [ ! -f "$script" ]; then \ _cz="$(chezmoi source-path 2>/dev/null || true)"; \ if [ -n "$_cz" ] && [ -f "$_cz/scripts/bookmark-pr-hygiene.sh" ]; then script="$_cz/scripts/bookmark-pr-hygiene.sh"; fi; \ fi; \ if [ ! -f "$script" ]; then printf 'repos::hygiene: missing script (tried %s and chezmoi source scripts/)\n' "$_jd/scripts/bookmark-pr-hygiene.sh" >&2; exit 1; fi; \ - bash "$script" audit {{ args }} + cd "$_inv" && bash "$script" audit {{ args }} # Prune landed branch/bookmark heads. Requires CONFIRM=1. hygiene-prune *args: @set -euo pipefail; \ if [ "${CONFIRM:-}" != "1" ]; then printf 'repos::hygiene-prune: set CONFIRM=1 to execute prune\n' >&2; exit 1; fi; \ _jd="{{ justfile_directory() }}"; \ + _inv="{{ invocation_directory() }}"; \ script="$_jd/scripts/bookmark-pr-hygiene.sh"; \ if [ ! -f "$script" ]; then \ _cz="$(chezmoi source-path 2>/dev/null || true)"; \ if [ -n "$_cz" ] && [ -f "$_cz/scripts/bookmark-pr-hygiene.sh" ]; then script="$_cz/scripts/bookmark-pr-hygiene.sh"; fi; \ fi; \ if [ ! -f "$script" ]; then printf 'repos::hygiene-prune: missing script (tried %s and chezmoi source scripts/)\n' "$_jd/scripts/bookmark-pr-hygiene.sh" >&2; exit 1; fi; \ - bash "$script" prune {{ args }} + cd "$_inv" && bash "$script" prune {{ args }} # Install the git pre-commit hook for this repo (markdown Prettier + whitespace). # Run once per clone. Requires the `pre-commit` CLI (e.g. brew install pre-commit) diff --git a/scripts/repos-smoke.sh b/scripts/repos-smoke.sh index e805ff7..11b8c89 100755 --- a/scripts/repos-smoke.sh +++ b/scripts/repos-smoke.sh @@ -10,8 +10,17 @@ fi audit_log="$(mktemp)" guard_log="$(mktemp)" -trap 'rm -f "$audit_log" "$guard_log"' EXIT +mock_dir="$(mktemp -d)" +trap 'rm -f "$audit_log" "$guard_log"; rm -rf "$mock_dir"' EXIT +MOCK_BIN="$mock_dir/bin" +mkdir -p "$MOCK_BIN" +PATH_ORIG="$PATH" +# shellcheck source=tests/shell/helpers/bookmark_pr_hygiene_mocks.sh +source "$repo_root/tests/shell/helpers/bookmark_pr_hygiene_mocks.sh" +activate_bookmark_hygiene_gh_mock + +cd "$repo_root" if just -f "$repo_root/repos.just" hygiene >"$audit_log" 2>&1; then : else diff --git a/tests/shell/helpers/bookmark_pr_hygiene_mocks.sh b/tests/shell/helpers/bookmark_pr_hygiene_mocks.sh new file mode 100644 index 0000000..9250752 --- /dev/null +++ b/tests/shell/helpers/bookmark_pr_hygiene_mocks.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash + +# Shared gh/jj/git mocks for bookmark-pr-hygiene shell tests. +# Requires MOCK_BIN to point at a writable bin directory. + +write_gh_mock() { + cat >"$MOCK_BIN/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [ "$1" = "repo" ] && [ "$2" = "view" ]; then + has_default=0 + for arg in "$@"; do + if [ "$arg" = "defaultBranchRef" ]; then has_default=1; fi + done + if [ "$has_default" -eq 1 ]; then + printf '%s\n' "${MOCK_DEFAULT_BRANCH:-master}" + else + printf '%s\n' "${MOCK_REPO:-aguil/dotfiles}" + fi + exit 0 +fi + +if [ "$1" = "pr" ] && [ "$2" = "list" ]; then + state="" + has_base=0 + has_oid=0 + while [ $# -gt 0 ]; do + case "$1" in + --state) state="$2"; shift ;; + --json) + case "$2" in + *baseRefName*) has_base=1 ;; + *headRefOid*) has_oid=1 ;; + esac + shift + ;; + esac + shift + done + if [ "$state" = "open" ]; then + printf '%s\n' "${MOCK_OPEN_COUNT:-0}" + exit 0 + fi + if [ "$state" = "merged" ]; then + if [ "$has_oid" -eq 1 ]; then + printf '%s\n' "${MOCK_MERGED_DEFAULT_EXACT:-0}" + exit 0 + fi + if [ "$has_base" -eq 1 ]; then + printf '%s\n' "${MOCK_MERGED_DEFAULT:-0}" + exit 0 + fi + printf '%s\n' "${MOCK_MERGED_TOTAL:-0}" + exit 0 + fi +fi + +if [ "$1" = "api" ]; then + if [ -n "${MOCK_COMPARE_STATUS:-}" ]; then + printf '%s\n' "$MOCK_COMPARE_STATUS" + fi + exit 0 +fi + +printf 'unexpected gh invocation: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$MOCK_BIN/gh" +} + +write_jj_mock() { + cat >"$MOCK_BIN/jj" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +cmd="${1:-}" +case "$cmd" in + root) + printf '%s\n' "${MOCK_JJ_ROOT:-/tmp/jj-root}" + ;; + bookmark) + sub="${2:-}" + case "$sub" in + list) + printf 'master: abcdef123456 base\n' + printf '%s: %s feature\n' "${MOCK_HEAD_NAME:-feat/test}" "${MOCK_HEAD_SHA:-111111111111}" + ;; + *) exit 1 ;; + esac + ;; + log) + printf '%s\n' "${MOCK_HEAD_SHA:-111111111111}" + ;; + git) + sub="${2:-}" + case "$sub" in + remote) + printf 'origin git@github.com:aguil/dotfiles.git\n' + ;; + *) exit 1 ;; + esac + ;; + *) + exit 1 + ;; +esac +EOF + chmod +x "$MOCK_BIN/jj" +} + +write_git_mock() { + cat >"$MOCK_BIN/git" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +cmd="${1:-}" +case "$cmd" in + rev-parse) + case "${2:-}" in + --is-inside-work-tree) + printf 'true\n' + ;; + --show-toplevel) + printf '%s\n' "${MOCK_GIT_ROOT:-/tmp/git-root}" + ;; + --git-dir) + printf '.git\n' + ;; + --git-common-dir) + printf '.git\n' + ;; + *) exit 1 ;; + esac + ;; + for-each-ref) + printf 'master\tabcdef123456\n' + printf '%s\t%s\n' "${MOCK_HEAD_NAME:-feat/test}" "${MOCK_HEAD_SHA:-111111111111}" + ;; + remote) + if [ "${2:-}" = "get-url" ]; then + printf 'git@github.com:aguil/dotfiles.git\n' + else + exit 1 + fi + ;; + *) + exit 1 + ;; +esac +EOF + chmod +x "$MOCK_BIN/git" +} + +write_jj_root_fail_mock() { + cat >"$MOCK_BIN/jj" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [ "${1:-}" = "root" ]; then + exit 1 +fi + +exit 1 +EOF + chmod +x "$MOCK_BIN/jj" +} + +export_bookmark_hygiene_gh_mock_defaults() { + export MOCK_COMPARE_STATUS="${MOCK_COMPARE_STATUS:-behind}" + export MOCK_OPEN_COUNT="${MOCK_OPEN_COUNT:-0}" + export MOCK_MERGED_TOTAL="${MOCK_MERGED_TOTAL:-0}" + export MOCK_MERGED_DEFAULT="${MOCK_MERGED_DEFAULT:-0}" + export MOCK_MERGED_DEFAULT_EXACT="${MOCK_MERGED_DEFAULT_EXACT:-0}" + export MOCK_REPO="${MOCK_REPO:-aguil/dotfiles}" + export MOCK_DEFAULT_BRANCH="${MOCK_DEFAULT_BRANCH:-master}" +} + +activate_bookmark_hygiene_gh_mock() { + export_bookmark_hygiene_gh_mock_defaults + write_gh_mock + PATH="$MOCK_BIN:$PATH_ORIG" +} diff --git a/tests/shell/integration/just_contracts.bats b/tests/shell/integration/just_contracts.bats index 0bc212c..4726f11 100644 --- a/tests/shell/integration/just_contracts.bats +++ b/tests/shell/integration/just_contracts.bats @@ -2,11 +2,23 @@ setup() { REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../../.." && pwd -P)" + TMP_CASE_DIR="$(mktemp -d)" + MOCK_BIN="$TMP_CASE_DIR/bin" + mkdir -p "$MOCK_BIN" + PATH_ORIG="$PATH" # shellcheck source=tests/shell/helpers/assert.sh source "$REPO_ROOT/tests/shell/helpers/assert.sh" + # shellcheck source=tests/shell/helpers/bookmark_pr_hygiene_mocks.sh + source "$REPO_ROOT/tests/shell/helpers/bookmark_pr_hygiene_mocks.sh" +} + +teardown() { + PATH="$PATH_ORIG" + rm -rf "$TMP_CASE_DIR" } @test "repos hygiene runs through public recipe" { + activate_bookmark_hygiene_gh_mock run just -f "$REPO_ROOT/repos.just" hygiene assert_status 0 "$status" @@ -14,6 +26,15 @@ setup() { assert_contains "$output" "Default branch:" } +@test "repos hygiene uses invocation directory not justfile directory" { + activate_bookmark_hygiene_gh_mock + cd "$REPO_ROOT/tests/shell" + run just -f "$REPO_ROOT/repos.just" hygiene + + assert_status 0 "$status" + assert_contains "$output" "Mode:" +} + @test "repos hygiene-prune guard blocks without CONFIRM" { run just -f "$REPO_ROOT/repos.just" hygiene-prune diff --git a/tests/shell/unit/bookmark_pr_hygiene.bats b/tests/shell/unit/bookmark_pr_hygiene.bats index beeccb3..80cc794 100644 --- a/tests/shell/unit/bookmark_pr_hygiene.bats +++ b/tests/shell/unit/bookmark_pr_hygiene.bats @@ -10,6 +10,8 @@ setup() { PATH="$MOCK_BIN:$PATH" # shellcheck source=tests/shell/helpers/assert.sh source "$REPO_ROOT/tests/shell/helpers/assert.sh" + # shellcheck source=tests/shell/helpers/bookmark_pr_hygiene_mocks.sh + source "$REPO_ROOT/tests/shell/helpers/bookmark_pr_hygiene_mocks.sh" } teardown() { @@ -17,169 +19,6 @@ teardown() { rm -rf "$TMP_CASE_DIR" } -write_gh_mock() { - cat >"$MOCK_BIN/gh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -if [ "$1" = "repo" ] && [ "$2" = "view" ]; then - has_default=0 - for arg in "$@"; do - if [ "$arg" = "defaultBranchRef" ]; then has_default=1; fi - done - if [ "$has_default" -eq 1 ]; then - printf '%s\n' "${MOCK_DEFAULT_BRANCH:-master}" - else - printf '%s\n' "${MOCK_REPO:-aguil/dotfiles}" - fi - exit 0 -fi - -if [ "$1" = "pr" ] && [ "$2" = "list" ]; then - state="" - has_base=0 - has_oid=0 - while [ $# -gt 0 ]; do - case "$1" in - --state) state="$2"; shift ;; - --json) - case "$2" in - *baseRefName*) has_base=1 ;; - *headRefOid*) has_oid=1 ;; - esac - shift - ;; - esac - shift - done - if [ "$state" = "open" ]; then - printf '%s\n' "${MOCK_OPEN_COUNT:-0}" - exit 0 - fi - if [ "$state" = "merged" ]; then - if [ "$has_oid" -eq 1 ]; then - printf '%s\n' "${MOCK_MERGED_DEFAULT_EXACT:-0}" - exit 0 - fi - if [ "$has_base" -eq 1 ]; then - printf '%s\n' "${MOCK_MERGED_DEFAULT:-0}" - exit 0 - fi - printf '%s\n' "${MOCK_MERGED_TOTAL:-0}" - exit 0 - fi -fi - -if [ "$1" = "api" ]; then - if [ -n "${MOCK_COMPARE_STATUS:-}" ]; then - printf '%s\n' "$MOCK_COMPARE_STATUS" - fi - exit 0 -fi - -printf 'unexpected gh invocation: %s\n' "$*" >&2 -exit 1 -EOF - chmod +x "$MOCK_BIN/gh" -} - -write_jj_mock() { - cat >"$MOCK_BIN/jj" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -cmd="${1:-}" -case "$cmd" in - root) - printf '%s\n' "${MOCK_JJ_ROOT:-/tmp/jj-root}" - ;; - bookmark) - sub="${2:-}" - case "$sub" in - list) - printf 'master: abcdef123456 base\n' - printf '%s: %s feature\n' "${MOCK_HEAD_NAME:-feat/test}" "${MOCK_HEAD_SHA:-111111111111}" - ;; - *) exit 1 ;; - esac - ;; - log) - printf '%s\n' "${MOCK_HEAD_SHA:-111111111111}" - ;; - git) - sub="${2:-}" - case "$sub" in - remote) - printf 'origin git@github.com:aguil/dotfiles.git\n' - ;; - *) exit 1 ;; - esac - ;; - *) - exit 1 - ;; -esac -EOF - chmod +x "$MOCK_BIN/jj" -} - -write_git_mock() { - cat >"$MOCK_BIN/git" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -cmd="${1:-}" -case "$cmd" in - rev-parse) - case "${2:-}" in - --is-inside-work-tree) - printf 'true\n' - ;; - --show-toplevel) - printf '%s\n' "${MOCK_GIT_ROOT:-/tmp/git-root}" - ;; - --git-dir) - printf '.git\n' - ;; - --git-common-dir) - printf '.git\n' - ;; - *) exit 1 ;; - esac - ;; - for-each-ref) - printf 'master\tabcdef123456\n' - printf '%s\t%s\n' "${MOCK_HEAD_NAME:-feat/test}" "${MOCK_HEAD_SHA:-111111111111}" - ;; - remote) - if [ "${2:-}" = "get-url" ]; then - printf 'git@github.com:aguil/dotfiles.git\n' - else - exit 1 - fi - ;; - *) - exit 1 - ;; -esac -EOF - chmod +x "$MOCK_BIN/git" -} - -write_jj_root_fail_mock() { - cat >"$MOCK_BIN/jj" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -if [ "${1:-}" = "root" ]; then - exit 1 -fi - -exit 1 -EOF - chmod +x "$MOCK_BIN/jj" -} - @test "classifies landed_on_default in jj mode" { export MOCK_COMPARE_STATUS="behind" export MOCK_OPEN_COUNT="0"