From 6a410a0fce203a3f0437d8ad29bf617dc476d0aa Mon Sep 17 00:00:00 2001 From: Rootiest Date: Wed, 23 Sep 2026 20:12:41 -0400 Subject: [PATCH] fix(agents-init): contain discovery to the project tree, prevent silent overwrite of real files --- functions/_agents_init_sync_instructions.fish | 17 +++- functions/agents-init.fish | 24 ++++-- tests/test-agents-init.fish | 84 +++++++++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish index ffee030..444012c 100644 --- a/functions/_agents_init_sync_instructions.fish +++ b/functions/_agents_init_sync_instructions.fish @@ -32,7 +32,10 @@ # 3. Any CLAUDE.md still left in the mirror once AGENTS.md is settled # (belt-and-suspenders past step 1) is removed. # 4. The project-level AGENTS.md symlink is (re)created if missing or -# stale, and any CLAUDE.md left at the project level is removed. +# stale, and any CLAUDE.md left at the project level is removed. A +# real project-level file found here (written after the mirror +# settled) is removed only if byte-identical to the mirror; if it +# differs, nothing is touched and a warning goes to stderr, as in 2. # # ARGUMENTS # root Absolute path to the project root @@ -176,6 +179,18 @@ function _agents_init_sync_instructions --argument-names root agents_dir rel set -l up (string repeat -n (count (string split / -- $rel)) "../") set target "$up""AGENTS/$rel/AGENTS.md" end + # A real (non-symlink) file here arrived after the mirror settled. Same + # rule as step 2: byte-identical to the mirror is a duplicate and is + # replaced below; different means touch nothing and warn. + for f in $proj_agents $proj_claude + if test -f "$f"; and not test -L "$f" + if not command diff -q "$f" "$mirror_agents" >/dev/null 2>&1 + echo "_agents_init_sync_instructions: $f and $mirror_agents differ; leaving both, resolve by hand" >&2 + return 0 + end + end + end + set -l need_link 1 if test -L "$proj_agents" test (readlink "$proj_agents") = "$target"; and set need_link 0 diff --git a/functions/agents-init.fish b/functions/agents-init.fish index c56231f..f7c541b 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -152,7 +152,9 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # directory created an AGENTS/ repo, two root symlinks, and a docs/ # tree there. set -l root (git rev-parse --show-toplevel 2>/dev/null) + set -l in_git 1 if test -z "$root" + set in_git 0 if test -e (pwd)/AGENTS.md -o -e (pwd)/CLAUDE.md -o -d (pwd)/AGENTS set root (pwd) else @@ -217,11 +219,23 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi # included, subdirectories found automatically rather than by a # hardcoded list. A real file, an already-migrated symlink, or a # leftover inverted-mirror survivor all match, so one pass covers - # fresh, migrated, and legacy state alike. AGENTS/ itself is - # pruned: it is the mirror, never a source to discover. - set -l found (find "$root" \ - \( -name .git -o -path "$agents_dir" -o -name node_modules \) -prune -o \ - \( -name AGENTS.md -o -name CLAUDE.md \) -print) + # fresh, migrated, and legacy state alike. + # + # Discovery stays inside this project: a non-git root (a lone + # agent file in, say, ~) syncs only itself -- walking it would + # reach into every unrelated tree below. In a git root, pruned: + # any AGENTS/ (a mirror, never a source), dot-directories (.git, + # .claude, .github: tool state, not scoped project dirs), + # node_modules, and nested repos/submodules/worktrees (their own + # .git marks another project). -mindepth 1 keeps the root itself, + # which has a .git, from pruning the whole walk. + set -l found + if test $in_git -eq 1 + set found (find "$root" -mindepth 1 \ + -type d \( -name '.*' -o -name AGENTS -o -name node_modules \ + -o -exec test -e '{}/.git' \; \) -prune -o \ + \( -name AGENTS.md -o -name CLAUDE.md \) -print) + end set -l rels "." for f in $found set -l d (path dirname "$f") diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index e0a319b..9aaf89a 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -215,5 +215,89 @@ check "stale gitignore: anchored /AGENTS.md line removed" false (grep -qxF '/AGE check "stale gitignore: anchored /CLAUDE.md line removed" false (grep -qxF '/CLAUDE.md' $e3/.gitignore; and echo true; or echo false) check "stale gitignore: unanchored AGENTS.md pattern present" true (grep -qxF 'AGENTS.md' $e3/.gitignore; and echo true; or echo false) +echo "" +echo "== agents-init: discovery stays out of nested repos and dot-dirs ==" + +set -l e4 (new_repo) +mkdir -p $e4/sub $e4/.claude $e4/vendor/other/AGENTS +git -C $e4/sub init -q +mkdir -p $e4/.gemini +echo nested-claude >$e4/sub/CLAUDE.md +echo tool-claude >$e4/.claude/CLAUDE.md +echo tool-agents >$e4/.gemini/AGENTS.md +echo foreign-mirror >$e4/vendor/other/AGENTS/CLAUDE.md +echo own-docs >$e4/vendor/CLAUDE.md +pushd $e4 >/dev/null +set -l ercE (agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "containment: exits 0" 0 "$ercE" +check "containment: nested repo got no AGENTS.md" false (test -e $e4/sub/AGENTS.md -o -L $e4/sub/AGENTS.md; and echo true; or echo false) +check "containment: nested repo CLAUDE.md still real" nested-claude (test -L $e4/sub/CLAUDE.md; or cat $e4/sub/CLAUDE.md) +check "containment: no mirror for nested repo" false (test -e $e4/AGENTS/sub; and echo true; or echo false) +check "containment: .claude/CLAUDE.md untouched" tool-claude (test -L $e4/.claude/CLAUDE.md; or cat $e4/.claude/CLAUDE.md) +check "containment: .gemini/AGENTS.md untouched" tool-agents (test -L $e4/.gemini/AGENTS.md; or cat $e4/.gemini/AGENTS.md) +check "containment: no mirror for .claude" false (test -e $e4/AGENTS/.claude; and echo true; or echo false) +check "containment: foreign AGENTS/ dir untouched" foreign-mirror (test -L $e4/vendor/other/AGENTS/CLAUDE.md; or cat $e4/vendor/other/AGENTS/CLAUDE.md) +check "containment: ordinary subdir still discovered" own-docs (cat $e4/AGENTS/vendor/AGENTS.md) +check "containment: ordinary subdir linked" ../AGENTS/vendor/AGENTS.md (readlink $e4/vendor/AGENTS.md) + +echo "" +echo "== agents-init: non-git root syncs only itself ==" + +set -l n1 (mktemp -d) +set -ga TMPDIRS $n1 +mkdir -p $n1/sub +echo root-agents >$n1/AGENTS.md +echo sub-agents >$n1/sub/AGENTS.md +mkdir -p $n1/sub2 +echo sub-claude >$n1/sub2/CLAUDE.md +pushd $n1 >/dev/null +set -l ercF (set -lx GIT_CEILING_DIRECTORIES (path dirname $n1); agents-init --agents --silent 2>/dev/null; echo $status) +popd >/dev/null +check "non-git: exits 0" 0 "$ercF" +check "non-git: root adopted into mirror" root-agents (cat $n1/AGENTS/AGENTS.md) +check "non-git: root linked" AGENTS/AGENTS.md (readlink $n1/AGENTS.md) +check "non-git: subdir AGENTS.md untouched" sub-agents (test -L $n1/sub/AGENTS.md; or cat $n1/sub/AGENTS.md) +check "non-git: subdir CLAUDE.md untouched" sub-claude (test -L $n1/sub2/CLAUDE.md; or cat $n1/sub2/CLAUDE.md) +check "non-git: no mirror for subdirs" false (test -e $n1/AGENTS/sub -o -e $n1/AGENTS/sub2; and echo true; or echo false) + +echo "" +echo "== _agents_init_sync_instructions: real file written after mirror settled ==" + +set -l s1 (new_repo) +mkdir -p $s1/AGENTS +echo settled >$s1/AGENTS/AGENTS.md +echo settled >$s1/AGENTS.md +echo settled >$s1/CLAUDE.md +set -l outS1 (_agents_init_sync_instructions $s1 $s1/AGENTS . 2>/dev/null) +set -l rcS1 $status +check "settled identical: exits 0" 0 "$rcS1" +check "settled identical: AGENTS.md replaced by link" AGENTS/AGENTS.md (readlink $s1/AGENTS.md) +check "settled identical: duplicate CLAUDE.md dropped" false (test -e $s1/CLAUDE.md; and echo true; or echo false) +check "settled identical: mirror intact" settled (cat $s1/AGENTS/AGENTS.md) + +set -l s2 (new_repo) +mkdir -p $s2/AGENTS +echo settled >$s2/AGENTS/AGENTS.md +echo rewritten >$s2/AGENTS.md +set -l errS2 (_agents_init_sync_instructions $s2 $s2/AGENTS . 2>&1 >/dev/null) +set -l rcS2 $status +check "settled differs (AGENTS.md): exits 0" 0 "$rcS2" +check "settled differs (AGENTS.md): real file kept" rewritten (test -L $s2/AGENTS.md; or cat $s2/AGENTS.md) +check "settled differs (AGENTS.md): warned on stderr" true (string match -q '*differ*' -- "$errS2"; and echo true; or echo false) +check "settled differs (AGENTS.md): mirror intact" settled (cat $s2/AGENTS/AGENTS.md) + +set -l s3 (new_repo) +mkdir -p $s3/AGENTS +echo settled >$s3/AGENTS/AGENTS.md +ln -s AGENTS/AGENTS.md $s3/AGENTS.md +echo recreated >$s3/CLAUDE.md +set -l errS3 (_agents_init_sync_instructions $s3 $s3/AGENTS . 2>&1 >/dev/null) +set -l rcS3 $status +check "settled differs (CLAUDE.md): exits 0" 0 "$rcS3" +check "settled differs (CLAUDE.md): real file kept" recreated (cat $s3/CLAUDE.md) +check "settled differs (CLAUDE.md): warned on stderr" true (string match -q '*differ*' -- "$errS3"; and echo true; or echo false) +check "settled differs (CLAUDE.md): link intact" AGENTS/AGENTS.md (readlink $s3/AGENTS.md) + cleanup report