From 346a211a233d0a5fb319b65326bf1bd7188dea0d Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 10 Apr 2026 13:54:12 -0400 Subject: [PATCH 1/2] hooks: auto-add .devkit/ to host repo .gitignore on first run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audit-trail.sh writes .devkit/audit.log on every Bash tool call. If the host repo's .gitignore doesn't already cover .devkit/, that file gets tracked on the first `git add .` and then conflicts on every subsequent stash pop / pull / merge because the hook keeps rewriting it. devkit's own .gitignore already has .devkit/, but the plugin can't reach into downstream repos — so every new install is one `git add .` away from this trap. Fix: on first run in a repo (detected via `.devkit/` not yet existing), `git rev-parse --show-toplevel` to find the repo root and append `.devkit/` to the root .gitignore if no matching entry exists. Idempotent (grep -qE '^\.devkit($|/)' skips if already present), worktree-safe (rev-parse handles worktrees), fails silently outside a git repo, and runs only once per repo so there's no per-command fork overhead after the first call. Does not rescue repos that already tracked audit.log before this change — those need a one-time `git rm --cached .devkit/audit.log`. --- hooks/audit-trail.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/hooks/audit-trail.sh b/hooks/audit-trail.sh index f7673b4..19f9264 100755 --- a/hooks/audit-trail.sh +++ b/hooks/audit-trail.sh @@ -14,6 +14,24 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') # Ensure log directory exists LOG_DIR=".devkit" LOG_FILE="${LOG_DIR}/audit.log" + +# First-run only: self-install .devkit/ in the host repo's root .gitignore. +# Without this, users who install devkit into a repo whose .gitignore doesn't +# already cover .devkit/ end up tracking audit.log, which then conflicts on +# stash/pull/merge because the hook rewrites it on every Bash call. +if [[ ! -d "$LOG_DIR" ]]; then + REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [[ -n "$REPO_ROOT" ]]; then + GITIGNORE="${REPO_ROOT}/.gitignore" + if [[ ! -f "$GITIGNORE" ]] || ! grep -qE '^\.devkit($|/)' "$GITIGNORE" 2>/dev/null; then + if [[ -f "$GITIGNORE" ]] && [[ -n "$(tail -c 1 "$GITIGNORE" 2>/dev/null)" ]]; then + printf '\n' >> "$GITIGNORE" + fi + printf '.devkit/\n' >> "$GITIGNORE" + fi + fi +fi + mkdir -p "$LOG_DIR" # Truncate command for log (first line only, max 500 chars) From 2f333b414104e2f4e034805bfccaaf102a2f8853 Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Fri, 10 Apr 2026 14:02:03 -0400 Subject: [PATCH 2/2] hooks: race-safe the .gitignore self-install via noclobber marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on PR #57 flagged a TOCTOU window in the first-run guard: parallel Claude Code Bash tool calls can both pass the `[[ ! -d .devkit ]]` check and both append `.devkit/` to the host repo's .gitignore, leaving a duplicate line. Harmless to git, but visible noise. Fix: switch the guard to an atomic noclobber init marker. 1. Fast-path check: `[[ -f .devkit/.gitignore-installed ]]` (no fork, just a stat — runs on every hook call but zero overhead after init). 2. Critical section: `( set -C; : > .gitignore-installed )` uses POSIX noclobber in a subshell, so only one process can successfully create the marker. Losers fail the redirection silently and skip the git work; the winner does the rev-parse + grep + append. Also documents the intentional `--show-toplevel` behavior in submodules (reviewer flagged as a potential bug, but it is correct: `.devkit/` is created relative to cwd, so the submodule's own .gitignore is the right file when running inside a submodule). Passes shellcheck. --- hooks/audit-trail.sh | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/hooks/audit-trail.sh b/hooks/audit-trail.sh index 19f9264..556fd6f 100755 --- a/hooks/audit-trail.sh +++ b/hooks/audit-trail.sh @@ -15,19 +15,34 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') LOG_DIR=".devkit" LOG_FILE="${LOG_DIR}/audit.log" -# First-run only: self-install .devkit/ in the host repo's root .gitignore. -# Without this, users who install devkit into a repo whose .gitignore doesn't -# already cover .devkit/ end up tracking audit.log, which then conflicts on -# stash/pull/merge because the hook rewrites it on every Bash call. -if [[ ! -d "$LOG_DIR" ]]; then - REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) - if [[ -n "$REPO_ROOT" ]]; then - GITIGNORE="${REPO_ROOT}/.gitignore" - if [[ ! -f "$GITIGNORE" ]] || ! grep -qE '^\.devkit($|/)' "$GITIGNORE" 2>/dev/null; then - if [[ -f "$GITIGNORE" ]] && [[ -n "$(tail -c 1 "$GITIGNORE" 2>/dev/null)" ]]; then - printf '\n' >> "$GITIGNORE" +# First-run only: self-install .devkit/ in the nearest git repo's .gitignore +# so devkit's audit log never gets accidentally tracked. Without this, users +# whose project .gitignore doesn't already cover .devkit/ end up tracking +# audit.log, which then conflicts on stash/pull/merge because the hook +# rewrites it on every Bash call. +# +# Race safety: Claude Code can fire PreToolUse hooks in parallel when the +# model issues parallel Bash calls. We use an atomic noclobber marker inside +# .devkit/ as the init lock so only the first process does the gitignore +# work; concurrent callers fail the noclobber and skip it cleanly. +# +# Submodule note: git rev-parse --show-toplevel intentionally returns the +# submodule root when cwd is inside one — this is correct because .devkit/ +# is created relative to cwd and therefore lives inside the submodule, so +# the submodule's own .gitignore is the right file to update. +INIT_MARKER="${LOG_DIR}/.gitignore-installed" +if [[ ! -f "$INIT_MARKER" ]]; then + mkdir -p "$LOG_DIR" + if ( set -C; : > "$INIT_MARKER" ) 2>/dev/null; then + REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [[ -n "$REPO_ROOT" ]]; then + GITIGNORE="${REPO_ROOT}/.gitignore" + if [[ ! -f "$GITIGNORE" ]] || ! grep -qE '^\.devkit($|/)' "$GITIGNORE" 2>/dev/null; then + if [[ -f "$GITIGNORE" ]] && [[ -n "$(tail -c 1 "$GITIGNORE" 2>/dev/null)" ]]; then + printf '\n' >> "$GITIGNORE" + fi + printf '.devkit/\n' >> "$GITIGNORE" fi - printf '.devkit/\n' >> "$GITIGNORE" fi fi fi