Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/repo-fleet-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "repo-fleet-hygiene",
"version": "0.22.4",
"version": "0.22.5",
"description": "Cross-repository Git/GitHub fleet discovery, evidence rollup, and a gated apply verb that executes a prior fleet action plan behind one confirmation. Audit stays read-only and confidence-tiered; apply mutates only with --apply plus interactive confirmation or --yes.",
"author": {
"name": "Melodic Software",
Expand Down
12 changes: 12 additions & 0 deletions plugins/repo-fleet-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
All notable changes to `repo-fleet-hygiene` are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.22.5]

### Fixed

- **Symlinked/junctioned intermediate directories under discovery are disclosed, not silent (#2711).**
Discovery still does not follow links. Each skipped intermediate symlink (or Windows directory
junction, which tests as a symlink under Git Bash) becomes an `UNKNOWN` `discovery-symlink-skip`
finding and is counted on the `Discovery skips:` header line. Symlinked discovery roots remain
refused as before. Fleet-level `UNKNOWN` findings (including these skips) now also move the
overall `Fleet verdict` off `CLEAN`, so a root whose only content is a skipped symlink cannot
report a clean walk.

## [0.22.4]

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions plugins/repo-fleet-hygiene/skills/audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ Related fleet contracts that remain separate:
marker) degrades the same way: an `UNKNOWN` `discovery-skip` finding, header skip counts, and the
rest of the fleet is still audited. An explicitly named `--repo` that is not a working tree still
hard-fails.
- A symlinked or junctioned intermediate directory under `--root` is not followed (same non-following
bound as before) but is disclosed as an `UNKNOWN` `discovery-symlink-skip` finding and counted on
the discovery-skips header line. Windows directory junctions test as symlinks under Git Bash, so
they take this path. Symlinked discovery *roots* remain a hard refusal (CLI) or `stale-config-entry`
(configured).
- `gh` missing/unauthenticated or API/timeout failure: continue Git/worktree checks, report GitHub
evidence as `UNKNOWN`, and make no merged/migration claim. Compatible `timeout`/`gtimeout` is
preferred; otherwise use the collector's finite TERM-to-KILL Bash watchdog.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ failure rather than a discovery.
| `local-ancestry-unavailable` | `git merge-base --is-ancestor` failed with an error status | `UNKNOWN` | Do not infer local ancestry |
| `stale-config-entry` | A config-sourced `fleet.root`/`fleet.repo` path is missing or not a Git working tree | `UNKNOWN` | Entry skipped, rest of the fleet still audited; correct or remove the entry |
| `discovery-skip` | A path discovered under `--root` is unreadable or not a Git working tree despite a `.git` marker | `UNKNOWN` | Path skipped, rest of the fleet still audited; inspect unexpected `.git` markers |
| `discovery-symlink-skip` | An intermediate directory under `--root` is a symbolic link (or a Windows directory junction, which tests as a symlink under Git Bash) | `UNKNOWN` | Path skipped without descending; rest of the fleet still audited. Pass an explicit `--root`/`--repo` for the link target if that tree should be in scope |

## What the tiers depend on

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ confirmation or `--yes`, re-derives OIDs before every delete, and skips fail-clo
- Branch refs and tips are buffered as NUL-delimited records with the `for-each-ref` exit status. A
partial producer failure discards every record, emits `UNKNOWN`, and does not increment the audited
repository count.
- Filesystem discovery is bounded and does not follow symbolic links.
- Filesystem discovery is bounded and does not follow symbolic links. Skipped intermediate
symlink (or Windows junction) directories are disclosed as `UNKNOWN` `discovery-symlink-skip`
findings and counted on the discovery-skips header line; the walk never descends through them.
- A 404 cannot be used to claim deletion/transfer because GitHub intentionally uses 404 for
access-sensitive cases; it remains `UNKNOWN`.
- A same-named branch in another repository cannot inherit PR status because every PR query includes
Expand Down
44 changes: 37 additions & 7 deletions plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,10 @@ DISCOVERY_SKIP_PATHS=()
DISCOVERY_SKIP_REASONS=()
DISCOVERY_NONREPO_COUNT=0
DISCOVERY_UNREADABLE_COUNT=0
# Intermediate directories that are symbolic links (or Windows junctions that test as symlinks
# under Git Bash). Discovery still does not follow them; each path is disclosed rather than omitted
# silently (#2711).
DISCOVERY_SYMLINK_PATHS=()

# Paths where core.bare=true coincides with working-tree content and/or linked worktrees. Rejecting
# these as "not a Git working tree" would omit the one administrative anomaly this collector exists
Expand Down Expand Up @@ -1208,12 +1212,21 @@ discover_repositories() {
fi
[[ "$depth" -lt "$MAX_DEPTH" ]] || return 0
for child in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do
[[ -d "$child" && ! -L "$child" ]] || continue
# Unmatched globs leave literal patterns; skip those. Broken symlinks still match -L.
[[ -e "$child" || -L "$child" ]] || continue
name="$(basename "$child")"
case "$name" in
. | .. | .git | node_modules | vendor | .venv) continue ;;
*) discover_repositories "$child" $((depth + 1)) ;;
*) ;; # other names fall through to the symlink/dir probes below
esac
# Symlinked/junctioned intermediate dirs: do not descend, but record for disclosure (#2711).
# Windows directory junctions satisfy both -d and -L under Git Bash, so they take this arm.
if [[ -L "$child" && -d "$child" ]]; then
DISCOVERY_SYMLINK_PATHS+=("$child")
continue
fi
[[ -d "$child" ]] || continue
discover_repositories "$child" $((depth + 1))
done
}

Expand Down Expand Up @@ -1267,7 +1280,7 @@ done
# contains no repositories is a valid empty audit ("0 repositories found"), not an error — the
# operator named that tree (#2599). Hard-fail only when nothing was ever in scope: no roots walked,
# no repos, and no stale/skip/bare-live entry to report.
if [[ ${#TARGETS[@]} -eq 0 && ${#STALE_CONFIG_PATHS[@]} -eq 0 && ${#DISCOVERY_SKIP_PATHS[@]} -eq 0 && ${#BARE_LIVE_TREE_PATHS[@]} -eq 0 && ${#ROOT_LABELS[@]} -eq 0 ]]; then
if [[ ${#TARGETS[@]} -eq 0 && ${#STALE_CONFIG_PATHS[@]} -eq 0 && ${#DISCOVERY_SKIP_PATHS[@]} -eq 0 && ${#DISCOVERY_SYMLINK_PATHS[@]} -eq 0 && ${#BARE_LIVE_TREE_PATHS[@]} -eq 0 && ${#ROOT_LABELS[@]} -eq 0 ]]; then
fail "no Git working trees found in the requested scope"
fi

Expand Down Expand Up @@ -2430,8 +2443,8 @@ done
# (or a directory is unreadable), the path is skipped rather than aborting. Report the counts so
# silence is not mistaken for a complete walk.
if [[ ${#ROOT_LABELS[@]} -gt 0 ]]; then
printf 'Discovery skips: %s non-repository, %s unreadable\n' \
"$DISCOVERY_NONREPO_COUNT" "$DISCOVERY_UNREADABLE_COUNT"
printf 'Discovery skips: %s non-repository, %s unreadable, %s symlink\n' \
"$DISCOVERY_NONREPO_COUNT" "$DISCOVERY_UNREADABLE_COUNT" "${#DISCOVERY_SYMLINK_PATHS[@]}"
fi

# Config-sourced entries that failed per-entry validation during argument processing (the header
Expand All @@ -2454,6 +2467,15 @@ for ((skip_index = 0; skip_index < ${#DISCOVERY_SKIP_PATHS[@]}; skip_index++));
"No action required for ordinary non-repositories; inspect unexpected .git markers, then rerun"
done

# Symlinked/junctioned intermediate directories under --root: still not followed, but never silent (#2711).
for ((symlink_index = 0; symlink_index < ${#DISCOVERY_SYMLINK_PATHS[@]}; symlink_index++)); do
printf '\n'
emit_finding UNKNOWN discovery-symlink-skip "${DISCOVERY_SYMLINK_PATHS[$symlink_index]}" \
Comment thread
kyle-sexton marked this conversation as resolved.
"symlinked intermediate directory skipped (discovery does not follow symbolic links; Windows directory junctions also test as symlinks under Git Bash)" \
"Path skipped; the rest of the fleet was audited" \
"Pass an explicit --root/--repo for the link target if that tree should be in scope, or replace the junction/symlink with a real directory"
done

# Bare repositories that still have working-tree content or linked worktrees (#2602). Reported
# before per-repo analysis so the anomaly is visible even when no worktree could be audited.
for ((bare_index = 0; bare_index < ${#BARE_LIVE_TREE_PATHS[@]}; bare_index++)); do
Expand Down Expand Up @@ -2548,16 +2570,24 @@ for ((ri = 0; ri < ${#R_DISCOVERED[@]}; ri++)); do
printf '%s\n' '---'
done

# Fleet-level findings (stale config, duplicate-checkout) get their own rollup row.
# Fleet-level findings (stale config, discovery skips/symlinks, duplicate-checkout)
# get their own rollup row. Their UNKNOWN gaps must also move the overall Fleet
# verdict off CLEAN — repo_verdict -1 already classifies them; count that here.
fleet_level_count=0
for ((i = 0; i < ${#F_KIND[@]}; i++)); do
[[ "${F_REPO_IDX[$i]}" == "-1" ]] && fleet_level_count=$((fleet_level_count + 1))
done
fleet_level_verdict="$(repo_verdict -1)"
if [[ "$fleet_level_count" -gt 0 ]]; then
printf 'Repo: fleet-level\n'
print_field Verdict "$(repo_verdict -1)"
print_field Verdict "$fleet_level_verdict"
print_field 'Kind counts' "$(repo_kind_counts_text -1)"
printf '%s\n' '---'
case "$fleet_level_verdict" in
BLOCKED*) fleet_blocked=$((fleet_blocked + 1)) ;;
CLEAN) ;;
*) fleet_candidates=$((fleet_candidates + 1)) ;;
esac
fi

case "$fleet_blocked" in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,31 @@ else
printf 'FAIL: symlink discovery root is refused rather than reporting zero repositories\n%s\n' "$(cat "$symlink_out")" >&2
failures=$((failures + 1))
fi

# Intermediate symlink/junction dirs under --root are skipped without descending, but must be
# disclosed (#2711). Linux symlinks are the portable fixture; Windows junctions take the same
# -d/-L arm under Git Bash.
sym_mid_root="$TMP/sym-mid-root"
sym_mid_hidden="$TMP/sym-mid-hidden"
mkdir -p "$sym_mid_root/keep" "$sym_mid_hidden/buried-repo/.git"
ln -s "$sym_mid_hidden" "$sym_mid_root/via-link"
sym_mid_out="$TMP/sym-mid-out.txt"
if REPO_FLEET_TEST_FAST_TIMEOUTS=1 bash "$SCRIPT" --root "$sym_mid_root" --detail >"$sym_mid_out" 2>&1; then
if grep -Fq "Finding: discovery-symlink-skip" "$sym_mid_out" &&
grep -Fq "Target: $sym_mid_root/via-link" "$sym_mid_out" &&
grep -Fq "Windows directory junctions" "$sym_mid_out" &&
grep -Fq "Discovery skips: 0 non-repository, 0 unreadable, 1 symlink" "$sym_mid_out" &&
grep -Fq "Fleet verdict: BLOCKED" "$sym_mid_out" &&
! grep -Fq "buried-repo" "$sym_mid_out"; then
printf 'PASS: intermediate symlink under --root is disclosed without descending\n'
else
printf 'FAIL: intermediate symlink under --root is disclosed without descending\n%s\n' "$(cat "$sym_mid_out")" >&2
failures=$((failures + 1))
fi
else
printf 'FAIL: intermediate symlink under --root unexpectedly aborted the run\n%s\n' "$(cat "$sym_mid_out")" >&2
failures=$((failures + 1))
fi
# Unreadable/non-executable roots are the same false-empty class.
unreadable_root="$TMP/unreadable-root"
mkdir -p "$unreadable_root"
Expand Down