fix(checkpoint): exclude protected dirs from first-checkpoint snapshot · Entire

fix(checkpoint): exclude protected dirs from first-checkpoint snapshot

262fd3f·

pjbgf·2d ago·5 files·+209 added/-7 removed

The first checkpoint of a session collected changed files via a raw git status parse in collectChangedFiles that only skipped .entire/ (IsInfrastructurePath). Agent-declared protected dirs/files — built-in (.claude) and external protocol-v1 plugins (protected_dirs) — were captured into the shadow-branch tree on session start, unlike the session-tracking and rewind paths which already honor them.

Add isProtectedCheckpointPath, mirroring shouldIgnoreSessionTrackingPath the two can't share code: cli imports checkpoint, and apply it at the three filter sites in collectChangedFiles.

Make path matching OS-based so case-insensitive filesystems exclude correctly: add paths.CaseInsensitiveFS (windows/darwin), paths.Equal, and fold case in IsSubpath on those platforms. On case-sensitive Linux the behavior is unchanged. Route protected-file equality in state.go and the new checkpoint helper through paths.Equal.

Regression tests: checkpoint first-checkpoint path (built-in + external plugin protected dir/file) and OS-aware paths unit tests.

Assisted-by: Claude Opus 4.8 noreply@anthropic.com Signed-off-by: Paulo Gomes paulo@entire.io

Sessions

01KXK2W6DRHMN0WSE7M51XZVMEView transcript

Changes

5

13 unmodified lines

// fakePluginAgent is a minimal agent stub used to prove that protected dirs
// and files reported by an external-plugin-style agent (via the AllProtectedDirs
// / AllProtectedFiles union) are honored by the first-checkpoint path, not just
// the built-in claude-code .claude dir.
type fakePluginAgent struct{}

var (
    _ agent.Agent                  = (*fakePluginAgent)(nil)
    _ agent.ProtectedFilesProvider = (*fakePluginAgent)(nil)
)

func (fakePluginAgent) Name() types.AgentName                { return "terminalhire-plugin" }
func (fakePluginAgent) Type() types.AgentType                { return "TerminalHire" }
func (fakePluginAgent) Description() string                  { return "fake external plugin for tests" }
func (fakePluginAgent) IsPreview() bool                      { return true }
func (fakePluginAgent) ProtectedDirs() []string              { return []string{ ".terminalhire" } }
func (fakePluginAgent) ProtectedFiles() []string             { return []string{ ".terminalhirerc" } }
func (fakePluginAgent) GetSessionID(*agent.HookInput) string { return "" }

// TestCollectChangedFiles_ExcludesProtectedDirs verifies that the
// first-checkpoint path keeps agent-protected dirs (e.g. .claude) and the
// .entire infrastructure dir out of the checkpoint snapshot, while ordinary
// untracked files are still captured. Regression for protected-dir content
// leaking into the shadow tree on session start.
func TestCollectChangedFiles_ExcludesProtectedDirs(t *testing.T) {
    t.Parallel()

// Register an external-plugin-style agent so its protected dir/file join the
    // AllProtectedDirs/AllProtectedFiles union alongside the built-in .claude.
    // Registration is additive and concurrency-safe; no test asserts the exact set.
    agent.Register("terminalhire-plugin", func() agent.Agent { return fakePluginAgent{} })

tempDir := t.TempDir()
    // Resolve symlinks so the repo root matches git's resolved path.
    // On macOS, t.TempDir() returns /var/... but git resolves to /private/var/... 
    tempDir, err := filepath.EvalSymlinks(tempDir)
    require.NoError(t, err)

testutil.InitRepo(t, tempDir)
    testutil.WriteFile(t, tempDir, "base.txt", "base")
    testutil.GitAdd(t, tempDir, "base.txt")
    testutil.GitCommit(t, tempDir, "init")

// Disable any global core.excludesFile so a developer/CI-runner gitignore
    // convention (e.g. one that ignores .claude) can't mask the leak. The fix
    // must exclude protected dirs on its own, independent of gitignore state.
    cfgCmd := exec.CommandContext(context.Background(), "git", "config", "core.excludesFile", os.DevNull)
    cfgCmd.Dir = tempDir
    require.NoError(t, cfgCmd.Run())

// Planted untracked, non-gitignored files.
    testutil.WriteFile(t, tempDir, ".claude/marker.txt", "MARKER-secret")         // built-in agent-protected dir
    testutil.WriteFile(t, tempDir, ".terminalhire/profile.json", "MARKER-plugin") // plugin-protected dir
    testutil.WriteFile(t, tempDir, ".terminalhirerc", "MARKER-plugin-file")       // plugin-protected file
    testutil.WriteFile(t, tempDir, ".entire/state.json", "{}"); // infrastructure
    testutil.WriteFile(t, tempDir, "src/keep.txt", "user work")                   // ordinary

repo, err := git.PlainOpen(tempDir)
    require.NoError(t, err)

result, err := collectChangedFiles(context.Background(), repo)
    require.NoError(t, err)

require.NotContains(t, result.Changed, ".claude/marker.txt",
        "built-in agent protected dir content must not be captured into the checkpoint")
    require.NotContains(t, result.Changed, ".terminalhire/profile.json",
        "external-plugin protected dir content must not be captured into the checkpoint")
    require.NotContains(t, result.Changed, ".terminalhirerc",
        "external-plugin protected file must not be captured into the checkpoint")
    require.NotContains(t, result.Changed, ".entire/state.json",
        "infrastructure dir must not be captured into the checkpoint")
    require.Contains(t, result.Changed, "src/keep.txt",
        "ordinary untracked files must still be captured")
}

// TestWriteCommitted_AgentField verifies that the Agent field is written
// to both metadata.json and the commit message trailer.
func TestWriteCommitted_AgentField(t *testing.T) {

"""

### Implementation of additional functions omitted for brevity