diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20c3c79..8d377d0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -44,8 +44,8 @@ jobs: with: persist-credentials: false - - name: Install BATS - run: sudo apt-get update && sudo apt-get install -y bats + - name: Install BATS and shells for integration tests + run: sudo apt-get update && sudo apt-get install -y bats fish zsh - name: Run tests run: bats tests/ diff --git a/README.md b/README.md index c7b88f8..882cbd8 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,11 @@ Review and approve executable commands defined in the repository's `.gtrconfig` git gtr trust # Review and approve .gtrconfig commands ``` -Trust is stored per repository path plus executable command definitions and must be re-approved if hooks or editor/AI defaults change. Hooks and defaults from your local git config (`.git/config`, `~/.gitconfig`) are always trusted. +Trust is bound to the repository path, the filesystem identity of its common Git directory, and executable command definitions. Replacing or recloning a repository at the same path, or changing hooks or editor/AI defaults, requires approval again. Older path-only approvals also require renewal. Hooks and defaults from your local git config (`.git/config`, `~/.gitconfig`) are always trusted. + +Approval covers command definitions and the behavior they delegate to. It does not hash or pin scripts, dependencies, or tools: for example, approving `sh ./scripts/setup.sh` allows subsequent changes to that script within the same repository. Review delegated code and trust the repository's contributors before approving commands. + +Trust requires a known filesystem birth time for the Git directory. If `stat` cannot provide it, `.gtrconfig` executable commands remain untrusted. ### Other Commands diff --git a/docs/configuration.md b/docs/configuration.md index 4f6211f..e3d48f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,10 @@ Create a `.gtrconfig` file in your repository root to share configuration across > [!IMPORTANT] > Hooks and editor/AI defaults from `.gtrconfig` are executable command entries. They are ignored until you review them with `git gtr trust`; changing those entries requires re-approval. +Approval is bound to the repository path and the filesystem identity of its common Git directory. A replacement or fresh clone at the same path needs new approval, as do approvals created by older versions that only used the path. Normal commits and linked worktrees retain approval. This is command-definition trust: scripts, dependencies, and tools invoked by approved commands are not hashed or pinned, so their contents can change within the trusted repository. Review delegated code and trust its contributors before approving. + +The identity includes device, inode, and birth time with nanosecond precision. If the filesystem or `stat` implementation cannot provide a known birth time, approval fails closed and `.gtrconfig` executable commands remain disabled. + --- ## Worktree Settings diff --git a/lib/commands/help.sh b/lib/commands/help.sh index ed0bab1..bc355c6 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -474,8 +474,17 @@ used until explicitly trusted. This prevents malicious contributors from injecting arbitrary shell commands via shared .gtrconfig files. Trust is stored per repository -path plus executable command definitions in ~/.config/gtr/trusted/ and -must be re-approved if those commands change. +path, common Git directory filesystem identity, and executable command +definitions in ~/.config/gtr/trusted/. Replacing a repository at the same +path or changing those commands requires new approval. Older path-only +approvals require renewal. + +Approval includes scripts and programs invoked by these commands. Their +contents are not hashed or pinned; review delegated code and trust its +contributors before approving. + +Trust requires a known filesystem birth time for the Git directory. +Without it, .gtrconfig executable commands remain untrusted. Hooks and defaults from your local git config (.git/config, ~/.gitconfig) are always trusted since you control those files directly. diff --git a/lib/commands/init.sh b/lib/commands/init.sh index e526744..26d3096 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -68,7 +68,7 @@ cmd_init() { # Generate output (cached to ~/.cache/gtr/, auto-invalidates on shell integration changes) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/gtr" local cache_file="$cache_dir/init-${func_name}.${shell}" - local cache_schema="${GTR_INIT_CACHE_VERSION:-6}" + local cache_schema="${GTR_INIT_CACHE_VERSION:-7}" local cache_stamp="# gtr-cache: version=${GTR_VERSION:-unknown} init=${cache_schema} func=$func_name shell=$shell" # Return cached output if version matches @@ -131,13 +131,28 @@ __SHELL_PRELUDE__ printf '%s/%s\n' "$_gtr_repo_root" "$(basename "$_gtr_config_file")" } +__FUNC___hooks_repo_identity() { +__SHELL_PRELUDE__ + local _gtr_repo_root="$1" + local _gtr_common_dir _gtr_identity + _gtr_common_dir="$(git -C "$_gtr_repo_root" rev-parse --git-common-dir 2>/dev/null)" || return 1 + _gtr_common_dir="$(cd "$_gtr_repo_root" && cd "$_gtr_common_dir" && pwd -P)" || return 1 + _gtr_identity="$(LC_ALL=C stat -c '%d:%i:%.9W' "$_gtr_common_dir" 2>/dev/null)" || + _gtr_identity="$(LC_ALL=C stat -f '%d:%i:%.9FB' "$_gtr_common_dir" 2>/dev/null)" || return 1 + case "${_gtr_identity##*:}" in + ''|0|0.*|*[!0-9.]*) return 1 ;; + esac + printf '%s\n%s\n' "$_gtr_common_dir" "$_gtr_identity" +} + __FUNC___hooks_current_trust_key() { __SHELL_PRELUDE__ local _gtr_config_file="$1" - local _gtr_hook_hash _gtr_repo_root + local _gtr_hook_hash _gtr_repo_root _gtr_repo_identity _gtr_hook_hash="$(__FUNC___hooks_current_content_hash "$_gtr_config_file" 2>/dev/null)" || return 1 _gtr_repo_root="$(__FUNC___hooks_repo_root "$_gtr_config_file" 2>/dev/null)" || return 1 - printf '%s\n%s\n' "$_gtr_repo_root" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 + _gtr_repo_identity="$(__FUNC___hooks_repo_identity "$_gtr_repo_root" 2>/dev/null)" || return 1 + printf 'v2\n%s\n%s\n%s\n' "$_gtr_repo_root" "$_gtr_repo_identity" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 } __FUNC___hooks_marker_matches_config() { @@ -200,13 +215,30 @@ function __FUNC___hooks_canonical_config_path printf '%s/%s\n' "$_gtr_repo_root" (basename "$_gtr_config_file") end +function __FUNC___hooks_repo_identity + # Keep the identity algorithm identical to the CLI and POSIX wrappers. + command sh -c ' + common_dir=$(git -C "$1" rev-parse --git-common-dir 2>/dev/null) || exit 1 + common_dir=$(cd "$1" && cd "$common_dir" && pwd -P) || exit 1 + identity=$(LC_ALL=C stat -c "%d:%i:%.9W" "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C stat -f "%d:%i:%.9FB" "$common_dir" 2>/dev/null) || exit 1 + case "${identity##*:}" in + ""|0|0.*|*[!0-9.]*) exit 1 ;; + esac + printf "%s\\n%s\\n" "$common_dir" "$identity" + ' sh "$argv[1]" +end + function __FUNC___hooks_current_trust_key set -l _gtr_config_file "$argv[1]" set -l _gtr_hook_hash (__FUNC___hooks_current_content_hash "$_gtr_config_file" 2>/dev/null) test -n "$_gtr_hook_hash"; or return 1 set -l _gtr_repo_root (__FUNC___hooks_repo_root "$_gtr_config_file" 2>/dev/null) test -n "$_gtr_repo_root"; or return 1 - printf '%s\n%s\n' "$_gtr_repo_root" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 + set -l _gtr_repo_identity (__FUNC___hooks_repo_identity "$_gtr_repo_root" 2>/dev/null | string collect) + test $pipestatus[1] -eq 0; or return 1 + test -n "$_gtr_repo_identity"; or return 1 + printf 'v2\n%s\n%s\n%s\n' "$_gtr_repo_root" "$_gtr_repo_identity" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 end function __FUNC___hooks_marker_matches_config diff --git a/lib/commands/trust.sh b/lib/commands/trust.sh index 01294c7..41298ea 100644 --- a/lib/commands/trust.sh +++ b/lib/commands/trust.sh @@ -10,6 +10,11 @@ cmd_trust() { return 0 fi + # Bind the definitions to the repository present before they are read. + local repo_root repo_identity + repo_root=$(_hooks_repo_root "$config_file") || return 1 + repo_identity=$(_hooks_repo_identity "$repo_root") || return 1 + # Show all trusted command entries from .gtrconfig local hook_content hook_content=$(_hooks_read_definitions "$config_file") || true @@ -27,7 +32,7 @@ cmd_trust() { fi local trust_path - trust_path=$(_hooks_reviewed_trust_path "$config_file" "$hook_content") || { + trust_path=$(_hooks_reviewed_trust_path "$config_file" "$hook_content" "$repo_identity") || { log_error "Failed to compute trust marker for $config_file" return 1 } @@ -37,13 +42,14 @@ cmd_trust() { printf '%s\n' "$hook_content" >&2 echo "" >&2 log_warn "These commands will execute on your machine during gtr operations." + log_warn "Approval includes scripts and programs they invoke; their contents are not pinned." if prompt_yes_no "Trust these commands?"; then if _hooks_write_trust_marker "$trust_path" "$config_file"; then local current_trust_path current_trust_path=$(_hooks_current_trust_path "$config_file") || true - if [ -n "$current_trust_path" ] && [ "$current_trust_path" != "$trust_path" ]; then - log_warn "Executable commands changed during review; current commands remain untrusted" + if [ "$current_trust_path" != "$trust_path" ]; then + log_warn "Executable commands or repository identity changed during review; current commands remain untrusted" return 1 fi log_info "Executable commands marked as trusted" diff --git a/lib/hooks.sh b/lib/hooks.sh index 4622a1c..17d6205 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -8,9 +8,10 @@ # files. # # Trust state is stored in ~/.config/gtr/trusted/ -# where is a SHA-256 of the canonical repo root plus trusted content hash. -# Trust is scoped to repo identity + trusted .gtrconfig definitions, not repo -# snapshot state. +# where binds the canonical repo root, Git directory identity and trusted +# content hash. Replacing a repository at the same path requires new approval. +# Trust approves command definitions, including any code they delegate to; it +# does not pin script contents, dependencies or repository snapshot state. _GTR_TRUST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gtr/trusted" @@ -56,6 +57,24 @@ _hooks_repo_root() { ) } +# Identify this repository instance, rather than just its reusable path. +# Use the common Git directory so normal commits and linked worktrees do not +# invalidate approval. Require a known birth time and preserve nanoseconds to +# distinguish rapid inode reuse. Filesystems without birth times fail closed. +# Do not use a repository-controlled config value or file as an identity token. +_hooks_repo_identity() { + local repo_root="$1" + local common_dir identity + common_dir=$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null) || return 1 + common_dir=$(cd "$repo_root" && cd "$common_dir" && pwd -P) || return 1 + identity=$(LC_ALL=C stat -c '%d:%i:%.9W' "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C stat -f '%d:%i:%.9FB' "$common_dir" 2>/dev/null) || return 1 + case "${identity##*:}" in + ''|0|0.*|*[!0-9.]*) return 1 ;; + esac + printf '%s\n%s\n' "$common_dir" "$identity" +} + # Resolve the canonical path for a .gtrconfig file # Usage: _hooks_canonical_config_path _hooks_canonical_config_path() { @@ -66,14 +85,18 @@ _hooks_canonical_config_path() { } # Compute the repo-scoped trust key for reviewed trusted command content -# Usage: _hooks_reviewed_trust_key +# Usage: _hooks_reviewed_trust_key [repo_identity] _hooks_reviewed_trust_key() { local config_file="$1" local hook_content="$2" + local repo_identity="${3:-}" local hash repo_root hash=$(_hooks_content_hash "$hook_content") || return 1 repo_root=$(_hooks_repo_root "$config_file") || return 1 - printf '%s\n%s\n' "$repo_root" "$hash" | shasum -a 256 | cut -d' ' -f1 + if [ -z "$repo_identity" ]; then + repo_identity=$(_hooks_repo_identity "$repo_root") || return 1 + fi + printf 'v2\n%s\n%s\n%s\n' "$repo_root" "$repo_identity" "$hash" | shasum -a 256 | cut -d' ' -f1 } # Compute the repo-scoped trust key for the current trusted command content @@ -91,12 +114,13 @@ _hooks_trust_key() { } # Resolve the trust marker path for reviewed trusted command content -# Usage: _hooks_reviewed_trust_path +# Usage: _hooks_reviewed_trust_path [repo_identity] _hooks_reviewed_trust_path() { local config_file="$1" local hook_content="$2" + local repo_identity="${3:-}" local trust_key - trust_key=$(_hooks_reviewed_trust_key "$config_file" "$hook_content") || return 1 + trust_key=$(_hooks_reviewed_trust_key "$config_file" "$hook_content" "$repo_identity") || return 1 printf '%s/%s\n' "$_GTR_TRUST_DIR" "$trust_key" } diff --git a/tests/cmd_trust.bats b/tests/cmd_trust.bats index a7c40ea..1a85fea 100644 --- a/tests/cmd_trust.bats +++ b/tests/cmd_trust.bats @@ -77,7 +77,7 @@ EOF [ "$rc" -eq 1 ] [ -f "$reviewed_trust_path" ] ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" - grep -F "Executable commands changed during review; current commands remain untrusted" "$output_file" + grep -F "Executable commands or repository identity changed during review; current commands remain untrusted" "$output_file" } @test "cmd_trust writes the reviewed marker when hooks change during confirmation" { @@ -99,9 +99,56 @@ EOF run cmd_trust [ "$status" -eq 1 ] - [[ "$output" == *"Executable commands changed during review; current commands remain untrusted"* ]] + [[ "$output" == *"Executable commands or repository identity changed during review; current commands remain untrusted"* ]] [ -f "$reviewed_marker" ] run _hooks_are_trusted "$TEST_REPO/.gtrconfig" [ "$status" -eq 1 ] } + +@test "cmd_trust rejects repository replacement during confirmation with identical definitions" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + prompt_yes_no() { + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement\n' > "$TEST_REPO/bootstrap.sh" + return 0 + } + run cmd_trust + [ "$status" -eq 1 ] + [[ "$output" == *"repository identity changed during review"* ]] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "cmd_trust fails closed when repository identity disappears during confirmation" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + prompt_yes_no() { + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + return 0 + } + run cmd_trust + [ "$status" -eq 1 ] + [[ "$output" == *"repository identity changed during review"* ]] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "cmd_trust snapshots identity before reading definitions" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + _hooks_read_definitions() { + local definitions + definitions=$(git config -f "$1" --get-regexp '^hooks\.') || return 1 + if [ ! -d "$TEST_REPO/original-git" ]; then + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement\n' > "$TEST_REPO/bootstrap.sh" + fi + printf '%s\n' "$definitions" + } + prompt_yes_no() { return 0; } + run cmd_trust + [ "$status" -eq 1 ] + [[ "$output" == *"repository identity changed during review"* ]] + [ ! -f "$(_hooks_current_trust_path "$TEST_REPO/.gtrconfig")" ] +} diff --git a/tests/hooks.bats b/tests/hooks.bats index a94543e..133938a 100644 --- a/tests/hooks.bats +++ b/tests/hooks.bats @@ -138,6 +138,7 @@ EOF @test "repo-specific trust markers differ for the same hook content" { local second_repo="$BATS_TMPDIR/second-repo" mkdir -p "$second_repo" + git -C "$second_repo" init --quiet cat > "$TEST_REPO/.gtrconfig" <<'EOF' [hooks] @@ -156,6 +157,88 @@ EOF [ "$first_path" != "$second_path" ] } +@test "replacement repository cannot reuse trust for identical definitions and changed delegated code" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'sh ./bootstrap.sh' + printf 'echo original > hook-result\n' > "$TEST_REPO/bootstrap.sh" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + local original_hash original_path + original_hash=$(_hooks_file_hash "$TEST_REPO/.gtrconfig") + original_path=$(_hooks_trust_path "$TEST_REPO/.gtrconfig") + run_hooks_in postCreate "$TEST_REPO" + [ "$(cat "$TEST_REPO/hook-result")" = original ] + + # Keep the old Git directory alive so this fixture cannot reuse its inode. + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement > hook-result\n' > "$TEST_REPO/bootstrap.sh" + rm "$TEST_REPO/hook-result" + [ "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" = "$original_hash" ] + run_hooks_in postCreate "$TEST_REPO" + [ ! -f "$TEST_REPO/hook-result" ] + [ "$(_hooks_trust_path "$TEST_REPO/.gtrconfig")" != "$original_path" ] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "legacy path-only approval cannot approve a repository instance" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + local legacy_key + legacy_key=$(printf '%s\n%s\n' "$TEST_REPO" "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" | + shasum -a 256 | cut -d' ' -f1) + mkdir -p "$_GTR_TRUST_DIR" + printf '%s\n' "$TEST_REPO/.gtrconfig" > "$_GTR_TRUST_DIR/$legacy_key" + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "deleting and recreating the Git directory cannot reuse approval" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + local original_identity iteration=0 + original_identity=$(_hooks_repo_identity "$TEST_REPO") + while [ "$iteration" -lt 10 ]; do + rm -rf "$TEST_REPO/.git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement\n' > "$TEST_REPO/bootstrap.sh" + [ "$(_hooks_repo_identity "$TEST_REPO")" != "$original_identity" ] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" + iteration=$((iteration + 1)) + done +} + +@test "unknown or unavailable Git directory birth times fail closed" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + stat() { printf '%s\n' "$fixture_stat"; } + local fixture_stat + for fixture_stat in '1:2:0.000000000' '1:2:0' '1:2:-' '1:2:-1.000000000'; do + ! _hooks_repo_identity "$TEST_REPO" + ! _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + done +} + +@test "definition trust survives delegated script changes in the same repository" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + printf 'echo updated\n' > "$TEST_REPO/bootstrap.sh" + _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "repository identity stays stable across commits and linked worktrees" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'echo harmless' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + git -C "$TEST_REPO" commit --allow-empty -m second --quiet + create_test_worktree identity-test + (cd "$TEST_WT_PATH" && _hooks_are_trusted "$TEST_REPO/.gtrconfig") +} + +@test "trust fails closed when repository identity cannot be resolved" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + _hooks_repo_identity() { return 1; } + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" + ! _hooks_mark_trusted "$TEST_REPO/.gtrconfig" +} + @test "run_hooks executes single hook" { git config --add gtr.hook.postCreate 'touch "$REPO_ROOT/hook-ran"' run_hooks postCreate REPO_ROOT="$TEST_REPO" diff --git a/tests/init.bats b/tests/init.bats index 2d759fa..f37d446 100644 --- a/tests/init.bats +++ b/tests/init.bats @@ -181,6 +181,60 @@ require_runtime_shell() { command -v "$shell_name" >/dev/null 2>&1 || skip "$shell_name is not installed" } +# Use real generated hooks and a harmless delegated script. Keep the definitions +# and canonical path identical when replacing the Git directory. +run_generated_replacement_repo_hooks() ( + set -e + local shell_name="$1" fixture + fixture=$(mktemp -d) + trap 'rm -rf "$fixture"' EXIT + export REPO_FIXTURE="$fixture/repo with spaces" + export INIT_FIXTURE="$fixture/init.$shell_name" + export XDG_CONFIG_HOME="$fixture/config" + mkdir -p "$REPO_FIXTURE" + git -C "$REPO_FIXTURE" init --quiet + git config -f "$REPO_FIXTURE/.gtrconfig" hooks.postCd 'sh "$REPO_ROOT/bootstrap.sh"' + printf 'echo original > "$REPO_ROOT/hook-result"\n' > "$REPO_FIXTURE/bootstrap.sh" + source "$PROJECT_ROOT/lib/hooks.sh" + _hooks_mark_trusted "$REPO_FIXTURE/.gtrconfig" + cmd_init "$shell_name" > "$INIT_FIXTURE" + "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_run_post_cd_hooks "$REPO_FIXTURE"' + [ "$(cat "$REPO_FIXTURE/hook-result")" = original ] + # Verify every generated identity helper fails closed without birth times. + mkdir "$fixture/bin" + printf '#!/bin/sh\necho 1:2:0.000000000\n' > "$fixture/bin/stat" + chmod +x "$fixture/bin/stat" + if PATH="$fixture/bin:$PATH" "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_hooks_repo_identity "$REPO_FIXTURE"'; then + return 1 + fi + mv "$REPO_FIXTURE/.git" "$fixture/original-git" + git -C "$REPO_FIXTURE" init --quiet + printf 'echo replacement > "$REPO_ROOT/hook-result"\n' > "$REPO_FIXTURE/bootstrap.sh" + rm "$REPO_FIXTURE/hook-result" + "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_run_post_cd_hooks "$REPO_FIXTURE"' + [ ! -f "$REPO_FIXTURE/hook-result" ] +) + +@test "bash generated hooks reject replacement repo with identical definitions" { + run run_generated_replacement_repo_hooks bash + [ "$status" -eq 0 ] + [[ "$output" == *"Untrusted .gtrconfig hooks skipped"* ]] +} + +@test "zsh generated hooks reject replacement repo with identical definitions" { + require_runtime_shell zsh + run run_generated_replacement_repo_hooks zsh + [ "$status" -eq 0 ] + [[ "$output" == *"Untrusted .gtrconfig hooks skipped"* ]] +} + +@test "fish generated hooks reject replacement repo with identical definitions" { + require_runtime_shell fish + run run_generated_replacement_repo_hooks fish + [ "$status" -eq 0 ] + [[ "$output" == *"Untrusted .gtrconfig hooks skipped"* ]] +} + # ── Default function name ──────────────────────────────────────────────────── @test "bash output defines gtr() function by default" { @@ -952,7 +1006,7 @@ require_runtime_shell() { @test "cache invalidates old default shell integration schema" { mkdir -p "$XDG_CACHE_HOME/gtr" - printf '%s\n%s\n' '# gtr-cache: version=test init=5 func=gtr shell=bash' old-cache > "$XDG_CACHE_HOME/gtr/init-gtr.bash" + printf '%s\n%s\n' '# gtr-cache: version=test init=6 func=gtr shell=bash' old-cache > "$XDG_CACHE_HOME/gtr/init-gtr.bash" run cmd_init bash @@ -960,19 +1014,19 @@ require_runtime_shell() { [ "$output" != "old-cache" ] local stamp stamp="$(head -1 "$XDG_CACHE_HOME/gtr/init-gtr.bash")" - [[ "$stamp" == *"init=6"* ]] + [[ "$stamp" == *"init=7"* ]] } @test "documented bash setup regenerates old schema cache" { mkdir -p "$XDG_CACHE_HOME/gtr" local _gtr_init="$XDG_CACHE_HOME/gtr/init-gtr.bash" - printf '%s\n%s\n' '# gtr-cache: version=test init=5 func=gtr shell=bash' old-cache > "$_gtr_init" + printf '%s\n%s\n' '# gtr-cache: version=test init=6 func=gtr shell=bash' old-cache > "$_gtr_init" cmd_init bash >/dev/null local stamp stamp="$(head -1 "$_gtr_init")" - [[ "$stamp" == *"init=6"* ]] + [[ "$stamp" == *"init=7"* ]] } @test "cache uses --as func name in cache key" {