diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 306f5fa048..0b5a413a71 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -123,6 +123,17 @@ type FileWatcher interface { OnFileChange(path string) (*SessionChange, error) } +// ProtectedFilesProvider is implemented by agents that need to exclude +// repo-root-relative files owned by the agent integration itself from session +// tracking or destructive operations. +type ProtectedFilesProvider interface { + Agent + + // ProtectedFiles returns repo-root-relative files that belong to the + // agent's own config/state and should be excluded from tracking. + ProtectedFiles() []string +} + // TranscriptAnalyzer provides format-specific transcript parsing. // Agents that implement this get richer checkpoints (transcript-derived file lists, // prompts, summaries). Agents that don't still participate in the checkpoint lifecycle diff --git a/cmd/entire/cli/agent/external/capabilities.go b/cmd/entire/cli/agent/external/capabilities.go index 6ad150bc52..95580e85aa 100644 --- a/cmd/entire/cli/agent/external/capabilities.go +++ b/cmd/entire/cli/agent/external/capabilities.go @@ -17,10 +17,14 @@ func Wrap(ea *Agent) (agent.Agent, error) { if ea == nil { return nil, errors.New("unable to wrap nil agent") } - return &wrappedAgent{ + base := &wrappedAgent{ ea: ea, caps: ea.info.Capabilities, - }, nil + } + if len(ea.info.ProtectedFiles) > 0 { + return &wrappedAgentWithProtectedFiles{wrappedAgent: base}, nil + } + return base, nil } // wrappedAgent forwards all agent.Agent and optional interface methods to the @@ -31,6 +35,12 @@ type wrappedAgent struct { caps agent.DeclaredCaps } +// wrappedAgentWithProtectedFiles opt-ins external agents to ProtectedFilesProvider +// only when the binary actually advertised protected_files in its info response. +type wrappedAgentWithProtectedFiles struct { + *wrappedAgent +} + // --- CapabilityDeclarer --- func (w *wrappedAgent) DeclaredCapabilities() agent.DeclaredCaps { return w.caps } @@ -120,6 +130,10 @@ func (w *wrappedAgent) WriteHookResponse(message string) error { return w.ea.WriteHookResponse(message) } +func (w *wrappedAgentWithProtectedFiles) ProtectedFiles() []string { + return w.ea.ProtectedFiles() +} + // --- SubagentAwareExtractor --- func (w *wrappedAgent) ExtractAllModifiedFiles(data []byte, offset int, dir string) ([]string, error) { diff --git a/cmd/entire/cli/agent/external/external.go b/cmd/entire/cli/agent/external/external.go index 45bba2c211..a1a2f36523 100644 --- a/cmd/entire/cli/agent/external/external.go +++ b/cmd/entire/cli/agent/external/external.go @@ -92,6 +92,10 @@ func (e *Agent) ProtectedDirs() []string { return e.info.ProtectedDirs } +func (e *Agent) ProtectedFiles() []string { + return e.info.ProtectedFiles +} + // --- Agent interface: Transcript Storage --- func (e *Agent) ReadTranscript(sessionRef string) ([]byte, error) { diff --git a/cmd/entire/cli/agent/external/types.go b/cmd/entire/cli/agent/external/types.go index 36bc955d47..25dbd33e77 100644 --- a/cmd/entire/cli/agent/external/types.go +++ b/cmd/entire/cli/agent/external/types.go @@ -22,6 +22,7 @@ type InfoResponse struct { Description string `json:"description"` IsPreview bool `json:"is_preview"` ProtectedDirs []string `json:"protected_dirs"` + ProtectedFiles []string `json:"protected_files"` HookNames []string `json:"hook_names"` Capabilities agent.DeclaredCaps `json:"capabilities"` } diff --git a/cmd/entire/cli/agent/opencode/entire_plugin.ts b/cmd/entire/cli/agent/opencode/entire_plugin.ts index 8ec339a62b..36bb6a95d3 100644 --- a/cmd/entire/cli/agent/opencode/entire_plugin.ts +++ b/cmd/entire/cli/agent/opencode/entire_plugin.ts @@ -47,10 +47,10 @@ export const EntirePlugin: Plugin = async ({ directory }) => { } /** - * Synchronous variant for hooks that fire near process exit (turn-end, session-end). - * `opencode run` breaks its event loop on the same session.status idle event that - * triggers turn-end. The async callHook would be killed before completing. - * Bun.spawnSync blocks the event loop, preventing exit until the hook finishes. + * Synchronous variant for hooks that must complete before subsequent agent work + * or process exit. `turn-start` must finish initializing session state before a + * fast mid-turn commit can hit git hooks, and `turn-end` / `session-end` must + * finish before `opencode run` tears down its event loop. */ function callHookSync(hookName: string, payload: Record) { try { @@ -66,6 +66,17 @@ export const EntirePlugin: Plugin = async ({ directory }) => { } } + function resetSessionTracking(sessionID: string) { + if (currentSessionID === sessionID) { + return false + } + seenUserMessages.clear() + messageStore.clear() + currentModel = null + currentSessionID = sessionID + return true + } + return { event: async ({ event }) => { try { @@ -74,10 +85,7 @@ export const EntirePlugin: Plugin = async ({ directory }) => { const session = (event as any).properties?.info if (!session?.id) break // Reset per-session tracking state when switching sessions. - if (currentSessionID !== session.id) { - seenUserMessages.clear() - messageStore.clear() - currentModel = null + if (resetSessionTracking(session.id)) { const json = JSON.stringify({ session_id: session.id, }) @@ -89,19 +97,40 @@ export const EntirePlugin: Plugin = async ({ directory }) => { }) await proc.exited } - currentSessionID = session.id break } case "message.updated": { const msg = (event as any).properties?.info if (!msg) break + + if (msg.sessionID && resetSessionTracking(msg.sessionID)) { + callHookSync("session-start", { + session_id: msg.sessionID, + }) + } + // Store message metadata (role, time, tokens, etc.) messageStore.set(msg.id, msg) // Track model from assistant messages if (msg.role === "assistant" && msg.modelID) { currentModel = msg.modelID } + + // Fallback: some opencode run flows commit before any message.part.updated + // event is delivered for the user's prompt. Start the turn from the + // user message itself so git hooks see an ACTIVE session in time. + if (msg.role === "user" && !seenUserMessages.has(msg.id)) { + seenUserMessages.add(msg.id) + const sessionID = msg.sessionID ?? currentSessionID + if (sessionID) { + callHookSync("turn-start", { + session_id: sessionID, + prompt: "", + model: currentModel ?? "", + }) + } + } break } @@ -115,7 +144,7 @@ export const EntirePlugin: Plugin = async ({ directory }) => { seenUserMessages.add(msg.id) const sessionID = msg.sessionID ?? currentSessionID if (sessionID) { - await callHook("turn-start", { + callHookSync("turn-start", { session_id: sessionID, prompt: part.text ?? "", model: currentModel ?? "", diff --git a/cmd/entire/cli/agent/opencode/hooks_test.go b/cmd/entire/cli/agent/opencode/hooks_test.go index bbcdd926a7..0267bffcdd 100644 --- a/cmd/entire/cli/agent/opencode/hooks_test.go +++ b/cmd/entire/cli/agent/opencode/hooks_test.go @@ -117,13 +117,11 @@ func TestInstallHooks_SessionStartIsGuardedBySessionSwitch(t *testing.T) { } content := string(data) - guard := "if (currentSessionID !== session.id) {" + guard := "if (resetSessionTracking(session.id)) {" hook := `const proc = Bun.spawn(hookCmd("session-start"), {` - currentSessionAssignment := "currentSessionID = session.id" guardIdx := strings.Index(content, guard) hookIdx := strings.Index(content, hook) - assignIdx := strings.Index(content, currentSessionAssignment) if guardIdx == -1 { t.Fatalf("plugin file missing guard %q", guard) @@ -131,18 +129,90 @@ func TestInstallHooks_SessionStartIsGuardedBySessionSwitch(t *testing.T) { if hookIdx == -1 { t.Fatalf("plugin file missing session-start hook spawn %q", hook) } - if assignIdx == -1 { - t.Fatalf("plugin file missing current session assignment %q", currentSessionAssignment) - } - if guardIdx >= hookIdx || hookIdx >= assignIdx { - t.Fatalf("expected guarded session-start call before session assignment, got guard=%d hook=%d assignment=%d", - guardIdx, hookIdx, assignIdx) + if guardIdx >= hookIdx { + t.Fatalf("expected guarded session-start call after guard, got guard=%d hook=%d", + guardIdx, hookIdx) } if !strings.Contains(content, `if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks opencode ${hookName}`) { t.Fatal("plugin file missing silent production hook command") } } +func TestInstallHooks_TurnStartUsesSyncHook(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { + t.Fatalf("install failed: %v", err) + } + + pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts") + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("plugin file not created: %v", err) + } + + content := string(data) + if !strings.Contains(content, `callHookSync("turn-start", {`) { + t.Fatal("plugin file should dispatch turn-start via callHookSync") + } + if strings.Contains(content, `await callHook("turn-start", {`) { + t.Fatal("plugin file should not dispatch turn-start via async callHook") + } +} + +func TestInstallHooks_MessageUpdatedFallsBackToSessionStart(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { + t.Fatalf("install failed: %v", err) + } + + pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts") + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("plugin file not created: %v", err) + } + + content := string(data) + if !strings.Contains(content, `if (msg.sessionID && resetSessionTracking(msg.sessionID)) {`) { + t.Fatal("plugin file should bootstrap session tracking from message.updated") + } + if !strings.Contains(content, `callHookSync("session-start", {`) { + t.Fatal("plugin file should dispatch fallback session-start via callHookSync") + } + if !strings.Contains(content, `session_id: msg.sessionID,`) { + t.Fatal("plugin file should pass msg.sessionID in fallback session-start") + } +} + +func TestInstallHooks_MessageUpdatedFallsBackToTurnStart(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ag := &OpenCodeAgent{} + + if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { + t.Fatalf("install failed: %v", err) + } + + pluginPath := filepath.Join(dir, ".opencode", "plugins", "entire.ts") + data, err := os.ReadFile(pluginPath) + if err != nil { + t.Fatalf("plugin file not created: %v", err) + } + + content := string(data) + if !strings.Contains(content, `if (msg.role === "user" && !seenUserMessages.has(msg.id)) {`) { + t.Fatal("plugin file should use message.updated as a fallback turn-start source") + } + if !strings.Contains(content, `prompt: "",`) { + t.Fatal("plugin file should send an empty prompt for fallback turn-start") + } +} + func TestInstallHooks_ForceReinstall(t *testing.T) { dir := t.TempDir() t.Chdir(dir) diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index b26047fff9..0d312d13ee 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -33,11 +33,12 @@ func NewOpenCodeAgent() agent.Agent { // --- Identity --- -func (a *OpenCodeAgent) Name() types.AgentName { return agent.AgentNameOpenCode } -func (a *OpenCodeAgent) Type() types.AgentType { return agent.AgentTypeOpenCode } -func (a *OpenCodeAgent) Description() string { return "OpenCode - AI-powered terminal coding agent" } -func (a *OpenCodeAgent) IsPreview() bool { return true } -func (a *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} } +func (a *OpenCodeAgent) Name() types.AgentName { return agent.AgentNameOpenCode } +func (a *OpenCodeAgent) Type() types.AgentType { return agent.AgentTypeOpenCode } +func (a *OpenCodeAgent) Description() string { return "OpenCode - AI-powered terminal coding agent" } +func (a *OpenCodeAgent) IsPreview() bool { return true } +func (a *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} } +func (a *OpenCodeAgent) ProtectedFiles() []string { return []string{"opencode.json"} } func (a *OpenCodeAgent) DetectPresence(ctx context.Context) (bool, error) { repoRoot, err := paths.WorktreeRoot(ctx) diff --git a/cmd/entire/cli/agent/opencode/transcript.go b/cmd/entire/cli/agent/opencode/transcript.go index b7b5f43f2a..3e75b88261 100644 --- a/cmd/entire/cli/agent/opencode/transcript.go +++ b/cmd/entire/cli/agent/opencode/transcript.go @@ -14,6 +14,7 @@ import ( var ( _ agent.TranscriptAnalyzer = (*OpenCodeAgent)(nil) _ agent.TranscriptPreparer = (*OpenCodeAgent)(nil) + _ agent.PromptExtractor = (*OpenCodeAgent)(nil) _ agent.TokenCalculator = (*OpenCodeAgent)(nil) ) @@ -280,6 +281,26 @@ func ExtractAllUserPrompts(data []byte) ([]string, error) { return prompts, nil } +// ExtractPrompts extracts user prompts from an OpenCode export transcript starting +// at the given message offset. +func (a *OpenCodeAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // path comes from validated agent session state + if err != nil { + return nil, fmt.Errorf("failed to read opencode transcript for prompt extraction: %w", err) + } + + scoped, err := SliceFromMessage(data, fromOffset) + if err != nil { + return nil, fmt.Errorf("failed to scope opencode transcript for prompt extraction: %w", err) + } + + prompts, err := ExtractAllUserPrompts(scoped) + if err != nil { + return nil, fmt.Errorf("failed to extract prompts from opencode transcript: %w", err) + } + return prompts, nil +} + // CalculateTokenUsage computes token usage from assistant messages starting at the given offset. func (a *OpenCodeAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) (*agent.TokenUsage, error) { session, err := ParseExportSession(transcriptData) diff --git a/cmd/entire/cli/agent/opencode/transcript_test.go b/cmd/entire/cli/agent/opencode/transcript_test.go index 1395226b26..ab009b0882 100644 --- a/cmd/entire/cli/agent/opencode/transcript_test.go +++ b/cmd/entire/cli/agent/opencode/transcript_test.go @@ -179,6 +179,28 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { } } +func TestExtractPrompts(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testExportJSON) + + prompts, err := ag.ExtractPrompts(path, 0) + require.NoError(t, err) + require.Equal(t, []string{"Fix the bug in main.go", "Also fix util.go"}, prompts) +} + +func TestExtractPrompts_FromOffset(t *testing.T) { + t.Parallel() + + ag := &OpenCodeAgent{} + path := writeTestTranscript(t, testExportJSON) + + prompts, err := ag.ExtractPrompts(path, 2) + require.NoError(t, err) + require.Equal(t, []string{"Also fix util.go"}, prompts) +} + func TestExtractFilePaths(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index cc2b201e9e..2651af2ed9 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -172,6 +172,34 @@ func AllProtectedDirs() []string { return dirs } +// AllProtectedFiles returns the union of ProtectedFiles from all registered agents. +func AllProtectedFiles() []string { + // Copy factories under the lock, then release before calling external code. + registryMu.RLock() + factories := make([]Factory, 0, len(registry)) + for _, f := range registry { + factories = append(factories, f) + } + registryMu.RUnlock() + + seen := make(map[string]struct{}) + var files []string + for _, factory := range factories { + pf, ok := factory().(ProtectedFilesProvider) + if !ok { + continue + } + for _, file := range pf.ProtectedFiles() { + if _, ok := seen[file]; !ok { + seen[file] = struct{}{} + files = append(files, file) + } + } + } + slices.Sort(files) + return files +} + // Default returns the default agent. // Returns nil if the default agent is not registered. // diff --git a/cmd/entire/cli/agent/registry_test.go b/cmd/entire/cli/agent/registry_test.go index 3789f08d83..52515196b0 100644 --- a/cmd/entire/cli/agent/registry_test.go +++ b/cmd/entire/cli/agent/registry_test.go @@ -252,11 +252,82 @@ func TestAllProtectedDirs(t *testing.T) { }) } +func TestAllProtectedFiles(t *testing.T) { + // Save original registry state + originalRegistry := make(map[types.AgentName]Factory) + registryMu.Lock() + for k, v := range registry { + originalRegistry[k] = v + } + registry = make(map[types.AgentName]Factory) + registryMu.Unlock() + + defer func() { + registryMu.Lock() + registry = originalRegistry + registryMu.Unlock() + }() + + t.Run("empty registry returns empty", func(t *testing.T) { + files := AllProtectedFiles() + if len(files) != 0 { + t.Errorf("expected empty files, got %v", files) + } + }) + + t.Run("collects files from registered agents", func(t *testing.T) { + registryMu.Lock() + registry = make(map[types.AgentName]Factory) + registryMu.Unlock() + + Register(types.AgentName("agent-no-files"), func() Agent { + return &mockAgent{} + }) + Register(types.AgentName("agent-a"), func() Agent { + return &protectedDirAgent{files: []string{"a.json"}} + }) + Register(types.AgentName("agent-b"), func() Agent { + return &protectedDirAgent{files: []string{"b.json", "shared.json"}} + }) + + files := AllProtectedFiles() + expected := []string{"a.json", "b.json", "shared.json"} + if len(files) != len(expected) { + t.Fatalf("expected %d files, got %d: %v", len(expected), len(files), files) + } + for i, file := range files { + if file != expected[i] { + t.Errorf("files[%d] = %q, want %q", i, file, expected[i]) + } + } + }) + + t.Run("deduplicates across agents", func(t *testing.T) { + registryMu.Lock() + registry = make(map[types.AgentName]Factory) + registryMu.Unlock() + + Register(types.AgentName("agent-x"), func() Agent { + return &protectedDirAgent{files: []string{"shared.json"}} + }) + Register(types.AgentName("agent-y"), func() Agent { + return &protectedDirAgent{files: []string{"shared.json"}} + }) + + files := AllProtectedFiles() + if len(files) != 1 { + t.Errorf("expected 1 file (deduplicated), got %d: %v", len(files), files) + } + }) +} + // protectedDirAgent is a mock that returns configurable protected dirs. type protectedDirAgent struct { mockAgent - dirs []string + dirs []string + files []string } -func (p *protectedDirAgent) ProtectedDirs() []string { return p.dirs } +func (p *protectedDirAgent) ProtectedDirs() []string { return p.dirs } +func (p *protectedDirAgent) ProtectedFiles() []string { return p.files } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index e7f45b10ba..0875f9d951 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -900,3 +901,53 @@ func TestHandleLifecycleTurnEnd_BackfillUpdatesSessionState(t *testing.T) { t.Errorf("expected LastPrompt 'second prompt', got %q", updated.LastPrompt) } } + +func TestHandleLifecycleTurnEnd_BackfillsPromptFromOpenCodeTranscript(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + + transcript := `{"info":{"id":"ses_test"},"messages":[{"info":{"id":"msg-1","role":"user","time":{"created":1708300000}},"parts":[{"type":"text","text":"create a file called notes/deep.md with a paragraph about deep validation. Do not ask for confirmation or approval, just make the change."}]},{"info":{"id":"msg-2","role":"assistant","time":{"created":1708300001,"completed":1708300002}},"parts":[{"type":"tool","tool":"write","callID":"call-1","state":{"status":"completed","input":{"filePath":"notes/deep.md"},"output":"ok"}}]}]}` + transcriptPath := filepath.Join(tmpDir, "transcript.json") + require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o600)) + + sessionID := "test-opencode-backfill" + ag := &opencode.OpenCodeAgent{} + event := &agent.Event{ + Type: agent.TurnEnd, + SessionID: sessionID, + SessionRef: transcriptPath, + Timestamp: time.Now(), + } + + repo, err := strategy.OpenRepository(context.Background()) + require.NoError(t, err) + head, err := repo.Head() + require.NoError(t, err) + state := &strategy.SessionState{ + SessionID: sessionID, + BaseCommit: head.Hash().String(), + LastPrompt: "", + } + require.NoError(t, strategy.SaveSessionState(context.Background(), state)) + + require.NoError(t, handleLifecycleTurnEnd(context.Background(), ag, event)) + + sessionDir := paths.SessionMetadataDirFromSessionID(sessionID) + sessionDirAbs, err := paths.AbsPath(context.Background(), sessionDir) + require.NoError(t, err) + + data, readErr := os.ReadFile(filepath.Join(sessionDirAbs, paths.PromptFileName)) + require.NoError(t, readErr) + require.Contains(t, string(data), "create a file called notes/deep.md") + + updated, loadErr := strategy.LoadSessionState(context.Background(), sessionID) + require.NoError(t, loadErr) + require.NotNil(t, updated) + require.Contains(t, updated.LastPrompt, "create a file called notes/deep.md") +} diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index 6006607060..d86843c496 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -225,6 +225,31 @@ type FileChanges struct { Deleted []string // Deleted files (staged or unstaged) } +// shouldIgnoreSessionTrackingPath returns true for repo files that belong to +// Entire or the agent integration itself rather than user work. +func shouldIgnoreSessionTrackingPath(relPath string) bool { + cleanPath := filepath.Clean(filepath.FromSlash(relPath)) + if paths.IsInfrastructurePath(cleanPath) { + return true + } + + for _, file := range agent.AllProtectedFiles() { + cleanFile := filepath.Clean(filepath.FromSlash(file)) + if cleanPath == cleanFile { + return true + } + } + + for _, dir := range agent.AllProtectedDirs() { + cleanDir := filepath.Clean(filepath.FromSlash(dir)) + if paths.IsSubpath(cleanDir, cleanPath) { + return true + } + } + + return false +} + // DetectFileChanges returns categorized file changes from the current git status. // // previouslyUntracked controls new-file detection: @@ -261,7 +286,7 @@ func DetectFileChanges(ctx context.Context, previouslyUntracked []string) (*File var changes FileChanges for file, st := range status { - if paths.IsInfrastructurePath(file) { + if shouldIgnoreSessionTrackingPath(file) { continue } @@ -363,8 +388,8 @@ func FilterAndNormalizePaths(files []string, cwd string) []string { if relPath == "" { continue // outside repo } - if paths.IsInfrastructurePath(relPath) { - continue // skip .entire directory + if shouldIgnoreSessionTrackingPath(relPath) { + continue } result = append(result, filepath.ToSlash(relPath)) } @@ -420,8 +445,7 @@ func getUntrackedFilesForState(ctx context.Context) ([]string, error) { untrackedFiles := []string{} for file, st := range status { if st.Worktree == git.Untracked { - // Exclude .entire directory - if !paths.IsInfrastructurePath(file) { + if !shouldIgnoreSessionTrackingPath(file) { untrackedFiles = append(untrackedFiles, file) } } diff --git a/cmd/entire/cli/state_test.go b/cmd/entire/cli/state_test.go index f8d88bc4a5..bdbf7b8b63 100644 --- a/cmd/entire/cli/state_test.go +++ b/cmd/entire/cli/state_test.go @@ -9,6 +9,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/object" "github.com/stretchr/testify/require" @@ -261,6 +262,18 @@ func TestFilterAndNormalizePaths_SiblingDirectories(t *testing.T) { // .entire path should be filtered }, }, + { + name: "agent-owned opencode paths are filtered", + files: []string{ + "/repo/src/file.ts", + "/repo/.opencode/plugins/entire.ts", + "/repo/opencode.json", + }, + basePath: "/repo", + want: []string{ + "src/file.ts", + }, + }, } for _, tt := range tests { @@ -466,6 +479,7 @@ func TestDetectFileChanges_DeletedFilesWithNilPreState(t *testing.T) { // This test verifies that DetectFileChanges detects deleted files // even when previouslyUntracked is nil. Deleted file detection // doesn't depend on pre-prompt state. + const trackedFileName = "tracked.txt" tmpDir := t.TempDir() t.Chdir(tmpDir) @@ -477,7 +491,7 @@ func TestDetectFileChanges_DeletedFilesWithNilPreState(t *testing.T) { } // Create and commit a tracked file - trackedFile := filepath.Join(tmpDir, "tracked.txt") + trackedFile := filepath.Join(tmpDir, trackedFileName) if err := os.WriteFile(trackedFile, []byte("tracked content"), 0o644); err != nil { t.Fatalf("failed to write tracked file: %v", err) } @@ -487,7 +501,7 @@ func TestDetectFileChanges_DeletedFilesWithNilPreState(t *testing.T) { t.Fatalf("failed to get worktree: %v", err) } - if _, err := worktree.Add("tracked.txt"); err != nil { + if _, err := worktree.Add(trackedFileName); err != nil { t.Fatalf("failed to add file: %v", err) } @@ -518,9 +532,9 @@ func TestDetectFileChanges_DeletedFilesWithNilPreState(t *testing.T) { // Deleted should contain the deleted tracked file if len(changes.Deleted) != 1 { - t.Errorf("DetectFileChanges(context.Background(),nil) Deleted = %v, want [tracked.txt]", changes.Deleted) - } else if changes.Deleted[0] != "tracked.txt" { - t.Errorf("DetectFileChanges(context.Background(),nil) Deleted[0] = %v, want tracked.txt", changes.Deleted[0]) + t.Errorf("DetectFileChanges(context.Background(),nil) Deleted = %v, want [%s]", changes.Deleted, trackedFileName) + } else if changes.Deleted[0] != trackedFileName { + t.Errorf("DetectFileChanges(context.Background(),nil) Deleted[0] = %v, want %s", changes.Deleted[0], trackedFileName) } } @@ -734,6 +748,70 @@ func TestDetectFileChanges_NilPreviouslyUntracked_ReturnsModified(t *testing.T) } } +func TestDetectFileChanges_IgnoresOpenCodeAgentFiles(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + testutil.InitRepo(t, tmpDir) + + trackedFile := filepath.Join(tmpDir, "tracked.txt") + if err := os.WriteFile(trackedFile, []byte("content"), 0o644); err != nil { + t.Fatalf("failed to write tracked file: %v", err) + } + + repo, err := git.PlainOpen(tmpDir) + if err != nil { + t.Fatalf("failed to open repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + if _, err := worktree.Add("tracked.txt"); err != nil { + t.Fatalf("failed to add file: %v", err) + } + + if _, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "test@example.com", + }, + }); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + if err := os.WriteFile(trackedFile, []byte("modified content"), 0o644); err != nil { + t.Fatalf("failed to modify tracked file: %v", err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, ".opencode", "plugins"), 0o755); err != nil { + t.Fatalf("failed to create .opencode/plugins: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, ".opencode", "plugins", "entire.ts"), []byte("// plugin"), 0o644); err != nil { + t.Fatalf("failed to write opencode plugin: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "opencode.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("failed to write opencode config: %v", err) + } + + changes, err := DetectFileChanges(context.Background(), nil) + if err != nil { + t.Fatalf("DetectFileChanges(context.Background(),nil) error = %v", err) + } + + if len(changes.Modified) != 1 || changes.Modified[0] != "tracked.txt" { + t.Errorf("DetectFileChanges(context.Background(),nil) Modified = %v, want [tracked.txt]", changes.Modified) + } + if len(changes.New) != 0 { + t.Errorf("DetectFileChanges(context.Background(),nil) New = %v, want empty", changes.New) + } + if len(changes.Deleted) != 0 { + t.Errorf("DetectFileChanges(context.Background(),nil) Deleted = %v, want empty", changes.Deleted) + } +} + func TestMergeUnique(t *testing.T) { t.Parallel()