feat(agents-init): protect deliberately git-tracked instruction files from being replaced

A real AGENTS.md/CLAUDE.md that is in git's index, in a project with a
non-empty .gitignore, is now left in place (with a stderr warning) by
steps 2 and 4 of _agents_init_sync_instructions instead of being moved
into AGENTS/ and symlinked. Discovery also prunes build/, dist/, out/
and target/ outright.
This commit is contained in:
2026-09-23 20:59:55 -04:00
parent 6a410a0fce
commit 2baa91f275
4 changed files with 243 additions and 15 deletions
@@ -0,0 +1,45 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# SYNOPSIS
# _agents_init_path_is_protected <root> <path>
#
# DESCRIPTION
# Decides whether a real (non-symlink) file should be left alone rather
# than adopted into the AGENTS/ mirror or replaced with a symlink,
# because it looks like a deliberately tracked project file rather than
# an incidental one this project hasn't yet engaged agents-init's
# convention for.
#
# A file is protected only when BOTH are true:
# - it is tracked in git's index at <root> -- staged or committed, via
# `git ls-files`. A file that has never been `git add`ed (even if it
# sits right next to tracked files) is not tracked by this
# definition, and neither is one that is merely gitignored.
# - <root>/.gitignore exists and is non-empty -- a project with no
# ignore rules at all has never engaged with the convention this
# tool manages, so a tracked file there is more likely incidental
# (e.g. the very first agents-init run, before anyone thought to
# ignore it) than a deliberate choice to keep tracking it.
#
# Neither check alone is enough: an untracked file is always safe
# regardless of .gitignore state (nothing has been committed to protect),
# and a tracked file in a project with no established ignore
# conventions is treated as adoptable rather than deliberate.
#
# ARGUMENTS
# root Absolute path to the project root (may or may not be a git repo)
# path Absolute path to the file being considered
#
# EXIT STATUS
# 0 Protected -- leave this file alone
# 1 Not protected -- safe to adopt/replace
#
# EXAMPLE
# _agents_init_path_is_protected /path/to/project /path/to/project/functions/CLAUDE.md
function _agents_init_path_is_protected --argument-names root path
test -n "$root" -a -n "$path"; or return 1
git -C "$root" ls-files --error-unmatch -- "$path" >/dev/null 2>&1; or return 1
test -s "$root/.gitignore"; or return 1
return 0
end
+42 -9
View File
@@ -1,6 +1,9 @@
# Copyright (C) 2026 Rootiest
# SPDX-License-Identifier: AGPL-3.0-or-later
# DEPENDENCIES
# _agents_init_path_is_protected
#
# CLASSIFICATION
# self-limiting(rm,mkdir), bypasses-shadow(mv)
#
@@ -15,15 +18,23 @@
# for that directory -- neither at the project level nor inside the
# mirror.
#
# The exception is a real file that is deliberately git-tracked -- in
# git's index, in a project whose .gitignore is non-empty (see
# _agents_init_path_is_protected). Such a file is never adopted,
# relinked, or removed: whenever steps 2 or 4 find one, they leave that
# directory's instruction files exactly as they are and warn on stderr.
#
# Four states of <rel> are handled, in order, so later steps only ever
# see a settled mirror:
#
# 1. The mirror itself is inverted (CLAUDE.md real, AGENTS.md symlinked
# to it). Flipped in place: same bytes, new name.
# 2. The mirror has no real AGENTS.md yet, and the project directory
# has one or both files. A lone real file (either name) is adopted
# as the mirror's AGENTS.md -- a lone CLAUDE.md is renamed, never
# preserved under its own name. Both real and byte-identical: the
# has one or both files. If either real file is protected, both are
# left untouched, the mirror is not populated, and a warning naming
# the protected file(s) goes to stderr. Otherwise a lone real file
# (either name) is adopted as the mirror's AGENTS.md -- a lone
# CLAUDE.md is renamed, never preserved under its own name. Both real and byte-identical: the
# AGENTS.md side is adopted and the duplicate CLAUDE.md is dropped.
# Both real and different: neither is touched and a warning is
# printed to stderr -- this function has no way to know which side
@@ -34,8 +45,10 @@
# 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. 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.
# settled) is checked for protection first, as in 2 -- a protected
# one is left alone even if byte-identical to the mirror. An
# unprotected one 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
@@ -44,8 +57,8 @@
# ("." for the root itself)
#
# EXIT STATUS
# 0 <rel> is settled (including the both-real-and-different skip, which
# is not a failure of this function)
# 0 <rel> is settled (including the both-real-and-different and
# protected-file skips, which are not failures of this function)
# 1 A filesystem operation (mkdir/mv/rm/ln) failed
#
# RETURNS
@@ -113,6 +126,17 @@ function _agents_init_sync_instructions --argument-names root agents_dir rel
test -f "$proj_agents"; and not test -L "$proj_agents"; and set has_agents 1
test -f "$proj_claude"; and not test -L "$proj_claude"; and set has_claude 1
# A deliberately git-tracked file is left alone -- and so is its
# sibling, since adopting one of a pair would still relink or drop
# the tracked one.
set -l protected
test $has_agents -eq 1; and _agents_init_path_is_protected "$root" "$proj_agents"; and set -a protected $disp_agents
test $has_claude -eq 1; and _agents_init_path_is_protected "$root" "$proj_claude"; and set -a protected $disp_claude
if set -q protected[1]
echo "_agents_init_sync_instructions: "(string join ', ' -- $protected)" tracked by git; leaving this directory's instruction files untouched" >&2
return 0
end
if test $has_agents -eq 1; and test $has_claude -eq 1
if command diff -q "$proj_agents" "$proj_claude" >/dev/null 2>&1
command mv "$proj_agents" "$mirror_agents"
@@ -180,8 +204,17 @@ function _agents_init_sync_instructions --argument-names root agents_dir 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.
# rules as step 2. A deliberately git-tracked one is left alone first,
# even if byte-identical: turning a tracked regular file into a symlink
# is itself a change to it. Otherwise, byte-identical to the mirror is a
# duplicate and is replaced below; different means touch nothing and warn.
set -l protected
test -f "$proj_agents"; and not test -L "$proj_agents"; and _agents_init_path_is_protected "$root" "$proj_agents"; and set -a protected $disp_agents
test -f "$proj_claude"; and not test -L "$proj_claude"; and _agents_init_path_is_protected "$root" "$proj_claude"; and set -a protected $disp_claude
if set -q protected[1]
echo "_agents_init_sync_instructions: "(string join ', ' -- $protected)" tracked by git; leaving this directory's instruction files untouched" >&2
return 0
end
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
+16 -6
View File
@@ -20,9 +20,15 @@
# agent-related files into it, and replaces them with symlinks so the
# outer project never tracks agent files directly. This applies at the
# project root and, automatically, to any subdirectory that carries its
# own scoped AGENTS.md or CLAUDE.md -- discovered by scanning the tree
# (pruning .git/, node_modules/, and AGENTS/ itself), not a hardcoded
# list.
# own scoped AGENTS.md or CLAUDE.md -- discovered by scanning the tree,
# not a hardcoded list. The scan prunes dot-directories (.git/, .claude/,
# ...), nested repos, AGENTS/ itself, node_modules/, and generated-output
# directories (build/, dist/, out/, target/).
#
# A real instruction file that the project deliberately tracks -- in
# git's index, in a project whose .gitignore is non-empty -- is left
# exactly where it is, with a warning, rather than moved into AGENTS/ and
# replaced by a symlink. See _agents_init_path_is_protected.
#
# Scaffolding runs only inside a git repository, or in a directory that
# already has an AGENTS.md, CLAUDE.md, or AGENTS/. Elsewhere it is a
@@ -226,13 +232,17 @@ function agents-init --description 'scaffold AGENTS/ sub-repo with agent spec fi
# 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.
# node_modules, generated-output directories (build, dist, out,
# target: an instruction file there is a build artifact, never a
# source -- pruned outright, before tracked-file protection would
# even be consulted), 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 -name build -o -name dist -o -name out -o -name target \
-o -exec test -e '{}/.git' \; \) -prune -o \
\( -name AGENTS.md -o -name CLAUDE.md \) -print)
end
+140
View File
@@ -299,5 +299,145 @@ 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)
echo ""
echo "== _agents_init_sync_instructions: deliberately git-tracked files are protected =="
# Tracked (committed) + populated .gitignore: left alone.
set -l p1 (new_repo)
mkdir -p $p1/AGENTS
echo node_modules/ >$p1/.gitignore
echo team-rules >$p1/AGENTS.md
git -C $p1 add .gitignore AGENTS.md
git -C $p1 commit -qm init
set -l errP1 (_agents_init_sync_instructions $p1 $p1/AGENTS . 2>&1 >/dev/null)
set -l rcP1 $status
check "tracked+ignore: exits 0" 0 "$rcP1"
check "tracked+ignore: still a real file" team-rules (test -L $p1/AGENTS.md; or cat $p1/AGENTS.md)
check "tracked+ignore: mirror not populated" false (test -e $p1/AGENTS/AGENTS.md; and echo true; or echo false)
check "tracked+ignore: still tracked, unmodified" "" (git -C $p1 status --porcelain -- AGENTS.md)
check "tracked+ignore: warned on stderr, naming the file" true (string match -q '*AGENTS.md tracked by git*' -- "$errP1"; and echo true; or echo false)
# Untracked because gitignored + populated .gitignore: adopted normally.
set -l p2 (new_repo)
mkdir -p $p2/AGENTS
echo 'AGENTS.md' >$p2/.gitignore
git -C $p2 add .gitignore
git -C $p2 commit -qm init
echo ignored-local >$p2/AGENTS.md
set -l outP2 (_agents_init_sync_instructions $p2 $p2/AGENTS . 2>/dev/null)
check "gitignored untracked: adopted into mirror" ignored-local (cat $p2/AGENTS/AGENTS.md)
check "gitignored untracked: linked" AGENTS/AGENTS.md (readlink $p2/AGENTS.md)
# Tracked but no .gitignore at all (bootstrap): adopted anyway.
set -l p3 (new_repo)
mkdir -p $p3/AGENTS
echo bootstrap >$p3/AGENTS.md
git -C $p3 add AGENTS.md
git -C $p3 commit -qm init
set -l outP3 (_agents_init_sync_instructions $p3 $p3/AGENTS . 2>/dev/null)
check "tracked, no .gitignore: adopted into mirror" bootstrap (cat $p3/AGENTS/AGENTS.md)
check "tracked, no .gitignore: linked" AGENTS/AGENTS.md (readlink $p3/AGENTS.md)
# Tracked but .gitignore empty: same bootstrap rule.
set -l p3b (new_repo)
mkdir -p $p3b/AGENTS
touch $p3b/.gitignore
echo bootstrap-empty >$p3b/AGENTS.md
git -C $p3b add .gitignore AGENTS.md
git -C $p3b commit -qm init
set -l outP3b (_agents_init_sync_instructions $p3b $p3b/AGENTS . 2>/dev/null)
check "tracked, empty .gitignore: adopted into mirror" bootstrap-empty (cat $p3b/AGENTS/AGENTS.md)
check "tracked, empty .gitignore: linked" AGENTS/AGENTS.md (readlink $p3b/AGENTS.md)
# Staged, never committed + populated .gitignore: staged is enough.
set -l p4 (new_repo)
mkdir -p $p4/AGENTS
echo node_modules/ >$p4/.gitignore
echo staged-only >$p4/AGENTS.md
git -C $p4 add AGENTS.md
set -l errP4 (_agents_init_sync_instructions $p4 $p4/AGENTS . 2>&1 >/dev/null)
check "staged-only: still a real file" staged-only (test -L $p4/AGENTS.md; or cat $p4/AGENTS.md)
check "staged-only: mirror not populated" false (test -e $p4/AGENTS/AGENTS.md; and echo true; or echo false)
check "staged-only: warned on stderr" true (string match -q '*tracked by git*' -- "$errP4"; and echo true; or echo false)
# Never added, not matched by .gitignore, populated .gitignore: adopted.
set -l p5 (new_repo)
mkdir -p $p5/AGENTS
echo node_modules/ >$p5/.gitignore
git -C $p5 add .gitignore
git -C $p5 commit -qm init
echo brand-new >$p5/CLAUDE.md
set -l outP5 (_agents_init_sync_instructions $p5 $p5/AGENTS . 2>/dev/null)
check "never added: adopted into mirror" brand-new (cat $p5/AGENTS/AGENTS.md)
check "never added: linked" AGENTS/AGENTS.md (readlink $p5/AGENTS.md)
check "never added: CLAUDE.md gone" false (test -e $p5/CLAUDE.md; and echo true; or echo false)
# A pair where only CLAUDE.md is tracked: both left, only CLAUDE.md named.
set -l p5b (new_repo)
mkdir -p $p5b/AGENTS
echo node_modules/ >$p5b/.gitignore
echo pair >$p5b/CLAUDE.md
git -C $p5b add .gitignore CLAUDE.md
git -C $p5b commit -qm init
echo pair >$p5b/AGENTS.md
set -l errP5b (_agents_init_sync_instructions $p5b $p5b/AGENTS . 2>&1 >/dev/null)
check "pair, one tracked: AGENTS.md left real" pair (test -L $p5b/AGENTS.md; or cat $p5b/AGENTS.md)
check "pair, one tracked: CLAUDE.md left real" pair (test -L $p5b/CLAUDE.md; or cat $p5b/CLAUDE.md)
check "pair, one tracked: mirror not populated" false (test -e $p5b/AGENTS/AGENTS.md; and echo true; or echo false)
check "pair, one tracked: names only the tracked file" true (string match -q '*: CLAUDE.md tracked by git*' -- "$errP5b"; and echo true; or echo false)
# Settled mirror (step 4): a tracked real file arriving later is protected,
# whether it differs from the mirror or is byte-identical to it.
set -l p7 (new_repo)
mkdir -p $p7/AGENTS
echo settled >$p7/AGENTS/AGENTS.md
ln -s AGENTS/AGENTS.md $p7/AGENTS.md
echo node_modules/ >$p7/.gitignore
echo team-claude >$p7/CLAUDE.md
git -C $p7 add .gitignore CLAUDE.md
git -C $p7 commit -qm init
set -l errP7 (_agents_init_sync_instructions $p7 $p7/AGENTS . 2>&1 >/dev/null)
check "settled, tracked differs: exits 0" 0 "$status"
check "settled, tracked differs: CLAUDE.md kept" team-claude (test -L $p7/CLAUDE.md; or cat $p7/CLAUDE.md)
check "settled, tracked differs: protection wins over diff warning" true (string match -q '*CLAUDE.md tracked by git*' -- "$errP7"; and echo true; or echo false)
check "settled, tracked differs: mirror intact" settled (cat $p7/AGENTS/AGENTS.md)
set -l p7b (new_repo)
mkdir -p $p7b/AGENTS
echo settled >$p7b/AGENTS/AGENTS.md
echo node_modules/ >$p7b/.gitignore
echo settled >$p7b/AGENTS.md
git -C $p7b add .gitignore AGENTS.md
git -C $p7b commit -qm init
set -l errP7b (_agents_init_sync_instructions $p7b $p7b/AGENTS . 2>&1 >/dev/null)
check "settled, tracked identical: still a real file" true (test -f $p7b/AGENTS.md; and not test -L $p7b/AGENTS.md; and echo true; or echo false)
check "settled, tracked identical: warned on stderr" true (string match -q '*AGENTS.md tracked by git*' -- "$errP7b"; and echo true; or echo false)
echo ""
echo "== agents-init: tracked subdir file protected, generated dirs pruned =="
set -l e5 (new_repo)
echo node_modules/ >$e5/.gitignore
mkdir -p $e5/team $e5/build $e5/dist $e5/out $e5/target
echo team-shared >$e5/team/CLAUDE.md
for g in build dist out target
echo gen-$g >$e5/$g/AGENTS.md
end
git -C $e5 add .gitignore team/CLAUDE.md build/AGENTS.md
git -C $e5 commit -qm init
pushd $e5 >/dev/null
set -l ercG (agents-init --agents --silent 2>/dev/null; echo $status)
popd >/dev/null
check "subdir protection: exits 0" 0 "$ercG"
check "subdir protection: team/CLAUDE.md still real" team-shared (test -L $e5/team/CLAUDE.md; or cat $e5/team/CLAUDE.md)
check "subdir protection: no team/AGENTS.md created" false (test -e $e5/team/AGENTS.md -o -L $e5/team/AGENTS.md; and echo true; or echo false)
check "subdir protection: no mirror file for team" false (test -e $e5/AGENTS/team/AGENTS.md; and echo true; or echo false)
check "subdir protection: team/CLAUDE.md unmodified in git" "" (git -C $e5 status --porcelain -- team/CLAUDE.md)
check "subdir protection: root still scaffolded" AGENTS/AGENTS.md (readlink $e5/AGENTS.md)
for g in build dist out target
check "pruned $g/: AGENTS.md untouched" gen-$g (test -L $e5/$g/AGENTS.md; or cat $e5/$g/AGENTS.md)
check "pruned $g/: no mirror" false (test -e $e5/AGENTS/$g; and echo true; or echo false)
end
cleanup
report