diff --git a/functions/_agents_init_path_is_protected.fish b/functions/_agents_init_path_is_protected.fish new file mode 100644 index 0000000..8eae44a --- /dev/null +++ b/functions/_agents_init_path_is_protected.fish @@ -0,0 +1,45 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# _agents_init_path_is_protected +# +# 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 -- 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. +# - /.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 diff --git a/functions/_agents_init_sync_instructions.fish b/functions/_agents_init_sync_instructions.fish index 444012c..a72926d 100644 --- a/functions/_agents_init_sync_instructions.fish +++ b/functions/_agents_init_sync_instructions.fish @@ -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 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 is settled (including the both-real-and-different skip, which -# is not a failure of this function) +# 0 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 diff --git a/functions/agents-init.fish b/functions/agents-init.fish index f7c541b..dd3a4ed 100644 --- a/functions/agents-init.fish +++ b/functions/agents-init.fish @@ -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 diff --git a/tests/test-agents-init.fish b/tests/test-agents-init.fish index 9aaf89a..59c3d3d 100644 --- a/tests/test-agents-init.fish +++ b/tests/test-agents-init.fish @@ -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