Skip to content

Commit edc15a4

Browse files
Sophclaude
andcommitted
cleanup: collapse duplicated reporting, and make two guards sound
Quality pass over the preceding six commits. No behaviour change except where noted; two of these close gaps the first versions left open. **Two guards were unsound.** `scanForSymlinkedComponent` tested `Type() == 0` for "a regular file where a directory belongs", which is a blocklist of one: a FIFO, socket or device node at `.claude` came back clean and doctor printed nothing, while os.Root and every hook install fail on it. Now an allowlist of the traversable shapes (`traversableComponent`), which is what the .entire scan's doc argues for at length — an allowlist a rejected type can enter by setting an extra bit is not an allowlist. fs.ModeIrregular stays tolerated: a Windows junction arrives as bare ModeIrregular and a cloud placeholder directory as ModeDir|ModeIrregular, and both are traversable. `RemoveDir` refused an `AllProtectedDirs()` entry, which is a blocklist and misses the case that matters: that list holds `.opencode` and `.github/hooks` but not `.opencode/plugins`, `.pi/extensions` or `.github`, so a future agent whose config sits one level below its root still got `RemoveAll(".opencode/plugins")` — the user's other plugins. Stated positively instead: the directory has to be one Entire named. Every agent root and shared intermediate fails that; pi's `.pi/extensions/entire` passes because Entire created it. **Duplication.** `printCappedList` replaces four inline copies of the same capped-list loop in doctor.go (three added by 761b87d, one pre-existing in checkEntireDirSymlinks), so the off-by-one truncation contract is written once. `testutil.SkipWithoutSymlinks` replaces six copies of the Windows symlink skip in three different wordings. `agentHelpSkillTemplate` is back to one switch on agentName, so a fourth agent cannot get a path with no body. The three worktreeFileName tests are one table, which also makes them agree visibly with worktreedir's four cases one layer down — and writing it out caught a wrong expectation of mine: a RELATIVE in-repo link never reaches the resolve, because os.Root follows it, so the fast path returns the original name. **Waste and dead state.** readCapped asked `utf8.ValidString` over the whole 6KB prefix up to four times to find a rune boundary; `utf8.RuneStart` at the cut answers the same question by looking at one byte (2081ns -> 2ns measured) and removes the whole-prefix fallback with it. A new test pins the floor, since a file of nothing but continuation bytes must still keep its content. The `vercelJSONName == ""` back-fill was dead — with either value the branch it guarded was skipped — so the name is now the sole presence signal and `loadVercelConfigIfPresent` says out loud that no vercel.json means nothing to read. worktreeFileName's third not-exist exit is gone: after a successful EvalSymlinks the re-stat can only fail on a race, which is not a state worth reading as absent. The guard test's helper returns the sorted slice both callers built by hand, dropping `sortedPackageDirs`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01M1GW5MCC47ZC1M29PTWJZ9Z2
1 parent 3e2fbf2 commit edc15a4

14 files changed

Lines changed: 382 additions & 233 deletions

cmd/entire/cli/agent/hook_config_file.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"os"
66
"path"
77
"path/filepath"
8-
"slices"
98

109
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
1110
"github.com/entireio/cli/cmd/entire/cli/osroot"
@@ -165,32 +164,40 @@ func (f *HookConfigFile) Remove() error {
165164
// correct uninstall and taking the parent would delete the user's own config
166165
// with it.
167166
//
168-
// Two refusals, because "every other agent must not call this" was a comment
169-
// and nothing else. A file directly at the worktree root would make the
170-
// directory to delete the repository; a file one level down makes it the
171-
// agent's own top-level directory, so `.claude/settings.json` would take
172-
// `.claude` — the user's hand-written settings, subagents and skills with it.
173-
// The second is the one a copy-paste during the next agent integration reaches,
174-
// which is the same argument the HookConfigLocator build guard makes: a
175-
// precondition this load-bearing belongs in the code, not in the doc comment
176-
// above it.
167+
// Enforced rather than described, because "every other agent must not call
168+
// this" was a comment and nothing else, and the call it guards is a recursive
169+
// delete. The precondition is stated positively: the directory has to be one
170+
// ENTIRE named, which is the only kind it created to hold a generated file.
171+
//
172+
// A blocklist of the agents' own directories was the obvious alternative and is
173+
// not sound. AllProtectedDirs() holds `.opencode` and `.github/hooks` but not
174+
// `.opencode/plugins`, `.pi/extensions` or `.github`, so deriving the target
175+
// with path.Dir and checking it against that list still permits
176+
// RemoveAll(".opencode/plugins") — the user's other OpenCode plugins — for any
177+
// future agent whose config sits one level deeper than its root. Every agent
178+
// root and every shared intermediate fails the name test instead, and pi's
179+
// `.pi/extensions/entire` passes it because Entire is what created it.
177180
func (f *HookConfigFile) RemoveDir() error {
178181
dir := path.Dir(f.name)
179182
if dir == "." {
180183
return fmt.Errorf("remove %s: refusing to remove the worktree root", filepath.Dir(f.path))
181184
}
182-
// Compared slash-to-slash: ProtectedDirs entries are repo-relative git-style
183-
// paths, and dir came from path.Dir of one.
184-
if slices.Contains(AllProtectedDirs(), dir) {
185-
return fmt.Errorf("remove %s: refusing to remove an agent's own directory; "+
186-
"RemoveDir is for a directory Entire created to hold one generated file", filepath.Dir(f.path))
185+
if path.Base(dir) != entireOwnedDirName {
186+
return fmt.Errorf("remove %s: refusing to remove %q, which Entire did not create; "+
187+
"RemoveDir is only for a directory named %q that holds one generated file",
188+
filepath.Dir(f.path), path.Base(dir), entireOwnedDirName)
187189
}
188190
if err := osroot.RemoveAllNoSymlinks(f.root, dir); err != nil {
189191
return fmt.Errorf("remove %s: %w", filepath.Dir(f.path), err)
190192
}
191193
return nil
192194
}
193195

196+
// entireOwnedDirName is the directory name Entire uses when it has to create a
197+
// directory of its own inside a tree an agent owns (`.pi/extensions/entire`).
198+
// RemoveDir keys its refusal on it.
199+
const entireOwnedDirName = "entire"
200+
194201
// Root exposes the underlying root and the file's name inside it, for the
195202
// callers that need a descriptor rather than the bytes. Both are Codex, which
196203
// bounds .codex/hooks.json on its stat size before reading any of it: Read is

cmd/entire/cli/agent/hook_config_file_test.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,20 @@ package agent_test
33
import (
44
"os"
55
"path/filepath"
6-
"runtime"
76
"testing"
87

98
"github.com/entireio/cli/cmd/entire/cli/agent"
109
"github.com/entireio/cli/cmd/entire/cli/osroot"
10+
"github.com/entireio/cli/cmd/entire/cli/testutil"
1111
"github.com/stretchr/testify/require"
1212
)
1313

14-
// skipWithoutSymlinks skips a test that needs to create one. On Windows that
15-
// takes elevation or developer mode, neither of which CI has.
14+
// skipWithoutSymlinks skips a test that needs to create one. Kept as a
15+
// package-local name for its callers; the condition lives in testutil so every
16+
// package that needs it says the same thing.
1617
func skipWithoutSymlinks(t *testing.T) {
1718
t.Helper()
18-
if runtime.GOOS == "windows" {
19-
t.Skip("symlink creation needs elevation on Windows")
20-
}
19+
testutil.SkipWithoutSymlinks(t)
2120
}
2221

2322
func TestHookConfig_ReadWriteRemoveRoundTrip(t *testing.T) {

cmd/entire/cli/agent_hook_config_guard_test.go

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,15 @@ func TestAllHookConfigRelPaths_CoversEveryWorktreeConfigAgent(t *testing.T) {
4545

4646
dir := strings.TrimSpace(string(repoRoot))
4747
callers := agentPackagesMatching(t, dir, "agent.OpenHookConfig(")
48-
require.NotEmpty(t, callers, "the detection pattern has gone stale and must be re-pointed")
4948
locators := agentPackagesMatching(t, dir, ") HookConfigRelPath() string {")
50-
require.NotEmpty(t, locators, "the detection pattern has gone stale and must be re-pointed")
5149

5250
// Sets, not counts. len(declared) == len(callers) passed whenever an added
5351
// omission was offset by a removal in the same change — the failure this
5452
// test exists to catch, since the agent still works and only doctor's
5553
// diagnosis goes quiet — and failed on an agent whose call happens to sit in
5654
// a sub-package, which is no defect at all. Both sides are package
5755
// directories, so they are directly comparable.
58-
require.Equal(t, sortedPackageDirs(locators), sortedPackageDirs(callers),
56+
require.Equal(t, locators, callers,
5957
"the agent packages calling agent.OpenHookConfig and those implementing\n"+
6058
"agent.HookConfigRelPath must be the same set. An agent that opens its\n"+
6159
"hook config without declaring where it lives leaves the directories\n"+
@@ -70,9 +68,11 @@ func TestAllHookConfigRelPaths_CoversEveryWorktreeConfigAgent(t *testing.T) {
7068
len(locators), len(agent.AllHookConfigRelPaths()), strings.Join(agent.AllHookConfigRelPaths(), ", "))
7169
}
7270

73-
// agentPackagesMatching returns the agent package directories whose non-test
74-
// sources contain needle.
75-
func agentPackagesMatching(t *testing.T, repoRoot, needle string) map[string]struct{} {
71+
// agentPackagesMatching returns the sorted, deduplicated agent package
72+
// directories whose non-test sources contain needle, asserting that the pattern
73+
// still matches something — a re-worded signature would otherwise turn this
74+
// guard into a comparison of two empty sets.
75+
func agentPackagesMatching(t *testing.T, repoRoot, needle string) []string {
7676
t.Helper()
7777
grep := exec.Command("git", "grep", "-l", "--fixed-strings", "--", //nolint:noctx // guard test, no cancellation needed
7878
needle, "--", ":(glob)cmd/entire/cli/agent/**/*.go")
@@ -85,21 +85,16 @@ func agentPackagesMatching(t *testing.T, repoRoot, needle string) map[string]str
8585
out, err := grep.Output()
8686
require.NoError(t, err, "no agent source matches %q, which cannot be right", needle)
8787

88-
pkgs := make(map[string]struct{})
88+
var pkgs []string
8989
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
9090
if line == "" || strings.HasSuffix(line, "_test.go") {
9191
continue
9292
}
93-
pkgs[path.Dir(line)] = struct{}{}
93+
if dir := path.Dir(line); !slices.Contains(pkgs, dir) {
94+
pkgs = append(pkgs, dir)
95+
}
9496
}
97+
slices.Sort(pkgs)
98+
require.NotEmpty(t, pkgs, "the detection pattern %q has gone stale and must be re-pointed", needle)
9599
return pkgs
96100
}
97-
98-
func sortedPackageDirs(m map[string]struct{}) []string {
99-
out := make([]string, 0, len(m))
100-
for k := range m {
101-
out = append(out, k)
102-
}
103-
slices.Sort(out)
104-
return out
105-
}

cmd/entire/cli/doctor.go

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"io/fs"
89
"log/slog"
910
"os"
1011
"path"
@@ -729,19 +730,29 @@ func checkEntireDirSymlinks(cmd *cobra.Command) {
729730
}
730731

731732
fmt.Fprintf(w, "%s contents: SYMLINKS PRESENT\n", paths.EntireDir)
732-
for i, name := range links {
733-
if i == symlinkReportLimit {
734-
fmt.Fprintf(w, " ... and %d more\n", len(links)-symlinkReportLimit)
735-
break
736-
}
737-
fmt.Fprintf(w, " %s -> %s\n", path.Join(paths.EntireDir, name), readlinkOrUnknownIn(root, name))
738-
}
733+
printCappedList(w, links, func(name string) string {
734+
return path.Join(paths.EntireDir, name) + " -> " + readlinkOrUnknownIn(root, name)
735+
})
739736
fmt.Fprintln(w, " Entire will not create or write through a symlinked directory here, so")
740737
fmt.Fprintln(w, " anything that belongs under one of these paths is not being captured.")
741738
fmt.Fprintln(w, " Fix: replace each path above with a real directory. If it is tracked in git,")
742739
fmt.Fprintln(w, " `git rm --cached` it first, and add it to .gitignore so it does not come back.")
743740
}
744741

742+
// printCappedList prints one indented line per name via render, replacing the
743+
// tail past symlinkReportLimit with a count. Four call sites had this loop
744+
// inline, differing only in the item line, so the off-by-one truncation
745+
// contract was written out four times.
746+
func printCappedList(w io.Writer, names []string, render func(string) string) {
747+
for i, name := range names {
748+
if i == symlinkReportLimit {
749+
fmt.Fprintf(w, " ... and %d more\n", len(names)-symlinkReportLimit)
750+
return
751+
}
752+
fmt.Fprintf(w, " %s\n", render(name))
753+
}
754+
}
755+
745756
// checkAgentDirSymlinks reports a symlink at any directory component Entire
746757
// creates or writes through for an agent: the agents' own config directories
747758
// (.claude, .codex, .cursor, .gemini, .factory, .opencode, .pi, .github/hooks)
@@ -813,13 +824,9 @@ func checkAgentDirSymlinks(cmd *cobra.Command) {
813824

814825
if len(links) > 0 {
815826
fmt.Fprintln(w, "Agent config directories: SYMLINKS PRESENT")
816-
for i, name := range links {
817-
if i == symlinkReportLimit {
818-
fmt.Fprintf(w, " ... and %d more\n", len(links)-symlinkReportLimit)
819-
break
820-
}
821-
fmt.Fprintf(w, " %s -> %s\n", name, readlinkOrUnknownIn(root, name))
822-
}
827+
printCappedList(w, links, func(name string) string {
828+
return name + " -> " + readlinkOrUnknownIn(root, name)
829+
})
823830
fmt.Fprintln(w, " Entire will not create or write through a symlinked path here, so the")
824831
fmt.Fprintln(w, " hooks and skills that belong under these paths are not installed, and")
825832
fmt.Fprintln(w, " `entire status` reports them as absent rather than as blocked.")
@@ -834,13 +841,9 @@ func checkAgentDirSymlinks(cmd *cobra.Command) {
834841
// ErrEntireDirUnreadable, for the same reason.
835842
if len(wrongType) > 0 {
836843
fmt.Fprintln(w, "Agent config directories: BROKEN")
837-
for i, name := range wrongType {
838-
if i == symlinkReportLimit {
839-
fmt.Fprintf(w, " ... and %d more\n", len(wrongType)-symlinkReportLimit)
840-
break
841-
}
842-
fmt.Fprintf(w, " %s is a file, but Entire needs a directory there\n", name)
843-
}
844+
printCappedList(w, wrongType, func(name string) string {
845+
return name + " is not a directory, but Entire needs one there"
846+
})
844847
fmt.Fprintln(w, " Entire cannot create the hooks and skills that belong under these paths,")
845848
fmt.Fprintln(w, " so `entire status` reports them as absent rather than as blocked.")
846849
fmt.Fprintln(w, " Fix: replace each path above with a real directory. If it is tracked in")
@@ -853,13 +856,7 @@ func checkAgentDirSymlinks(cmd *cobra.Command) {
853856
// install and a doctor that says nothing.
854857
if len(unreadable) > 0 {
855858
fmt.Fprintln(w, "Agent config directories: NOT READABLE")
856-
for i, name := range unreadable {
857-
if i == symlinkReportLimit {
858-
fmt.Fprintf(w, " ... and %d more\n", len(unreadable)-symlinkReportLimit)
859-
break
860-
}
861-
fmt.Fprintf(w, " %s\n", name)
862-
}
859+
printCappedList(w, unreadable, func(name string) string { return name })
863860
fmt.Fprintln(w, " Entire could not tell whether these paths are real directories, so it")
864861
fmt.Fprintln(w, " cannot say whether hooks and skills can be installed under them.")
865862
fmt.Fprintln(w, " Fix: check the ownership and permissions of each path above.")
@@ -979,21 +976,36 @@ func scanForSymlinkedComponent(root *os.Root, name string) (string, componentSca
979976
if info.Mode()&os.ModeSymlink != 0 {
980977
return prefix, componentScanLinked
981978
}
982-
// A regular file with components still to go is a different fault from
983-
// an unreadable one, and it is identified here from the mode rather than
984-
// from the ENOTDIR the next Lstat would return: Type() == 0 is a regular
985-
// file positively, where matching an errno would also have to be right
986-
// about which errno each platform picks. fs.ModeIrregular is left out of
987-
// this test on purpose, the same way the .entire scan tolerates it —
988-
// Windows lands directory junctions and cloud placeholders on that bit,
989-
// and both are traversable.
990-
if info.Mode().Type() == 0 && i < len(components)-1 {
979+
// A component with more path still to go has to be a directory. Stated
980+
// as an allowlist of the traversable shapes, not as a test for a regular
981+
// file: the .entire scan's doc spends a paragraph on why an allowlist a
982+
// rejected type can enter by setting an extra bit is not an allowlist,
983+
// and the narrower version here missed a FIFO, socket or device node at
984+
// `.claude` entirely — os.Root and every hook install fail on one, and
985+
// doctor printed nothing.
986+
//
987+
// Identified from the mode rather than from the ENOTDIR the next Lstat
988+
// would return, which would mean being right about which errno each
989+
// platform picks. fs.ModeIrregular is tolerated the way the .entire scan
990+
// tolerates it: Windows maps directory junctions and cloud placeholders
991+
// onto that bit and both are traversable, and a junction arrives as bare
992+
// ModeIrregular (a name-surrogate reparse tag withholds ModeDir) while a
993+
// placeholder directory arrives as ModeDir|ModeIrregular.
994+
if prefix != name && !traversableComponent(info.Mode()) {
991995
return prefix, componentScanWrongType
992996
}
993997
}
994998
return "", componentScanClean
995999
}
9961000

1001+
// traversableComponent reports whether mode can hold a path below it: a real
1002+
// directory, or one of the two shapes Windows expresses with fs.ModeIrregular.
1003+
// See scanForSymlinkedComponent for why that bit is tolerated.
1004+
func traversableComponent(mode fs.FileMode) bool {
1005+
t := mode.Type()
1006+
return t == fs.ModeDir || t == fs.ModeIrregular || t == fs.ModeDir|fs.ModeIrregular
1007+
}
1008+
9971009
// readlinkOrUnknown renders a symlink's target for a diagnostic, never failing:
9981010
// an unreadable link is still worth naming.
9991011
func readlinkOrUnknown(name string) string {

0 commit comments

Comments
 (0)