diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 11269e359..cc2b201e9 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -118,7 +118,7 @@ const ( AgentTypeFactoryAIDroid types.AgentType = "Factory AI Droid" AgentTypeGemini types.AgentType = "Gemini CLI" AgentTypeOpenCode types.AgentType = "OpenCode" - AgentTypeUnknown types.AgentType = "Agent" // Fallback for backwards compatibility + AgentTypeUnknown types.AgentType = "Unknown" ) // DefaultAgentName is the registry key for the default agent. diff --git a/cmd/entire/cli/agent/registry_test.go b/cmd/entire/cli/agent/registry_test.go index c456e4287..3789f08d8 100644 --- a/cmd/entire/cli/agent/registry_test.go +++ b/cmd/entire/cli/agent/registry_test.go @@ -144,6 +144,9 @@ func TestAgentNameConstants(t *testing.T) { } func TestDefaultAgentName(t *testing.T) { + // DefaultAgentName is for the `entire enable` setup flow when no agent is + // detected. It is NOT used for agent attribution fallbacks — those use + // AgentTypeUnknown ("Unknown") or "Unknown" in the DB. if DefaultAgentName != AgentNameClaudeCode { t.Errorf("expected DefaultAgentName %q, got %q", AgentNameClaudeCode, DefaultAgentName) } diff --git a/cmd/entire/cli/commit_message_test.go b/cmd/entire/cli/commit_message_test.go index 0a86426c4..62bc05279 100644 --- a/cmd/entire/cli/commit_message_test.go +++ b/cmd/entire/cli/commit_message_test.go @@ -248,10 +248,10 @@ func TestGenerateCommitMessage(t *testing.T) { expected: "OpenCode session updates", }, { - name: "returns Agent fallback for empty agent type", + name: "returns Unknown fallback for empty agent type", prompt: "", agentType: "", - expected: "Agent session updates", + expected: "Unknown session updates", }, } diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index b8a9b8d7d..72900240c 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -689,22 +689,45 @@ func ReadAgentTypeFromTree(tree *object.Tree, checkpointPath string) types.Agent } } - // Fall back to detecting agent from config files (shadow branches don't have metadata.json). - // Order: Gemini (most specific check), Claude (established default), OpenCode (newest/preview). + // Fall back to detecting agent from config markers (shadow branches don't have metadata.json). + // Multiple agent config markers may coexist when users configure multiple agents via + // `entire configure`. Only return a specific agent type when exactly one agent config + // marker (directory or file) is present; otherwise return Unknown since we can't + // determine which agent created the checkpoint. + var detected types.AgentType + detectedCount := 0 + if _, err := tree.File(".gemini/settings.json"); err == nil { - return agent.AgentTypeGemini + detected = agent.AgentTypeGemini + detectedCount++ } if _, err := tree.Tree(".claude"); err == nil { - return agent.AgentTypeClaudeCode + detected = agent.AgentTypeClaudeCode + detectedCount++ } - // OpenCode: .opencode directory or opencode.json config if _, err := tree.Tree(".opencode"); err == nil { - return agent.AgentTypeOpenCode + detected = agent.AgentTypeOpenCode + detectedCount++ + } else if _, err := tree.File("opencode.json"); err == nil { + detected = agent.AgentTypeOpenCode + detectedCount++ + } + if _, err := tree.Tree(".codex"); err == nil { + detected = agent.AgentTypeCodex + detectedCount++ } - if _, err := tree.File("opencode.json"); err == nil { - return agent.AgentTypeOpenCode + if _, err := tree.Tree(".cursor"); err == nil { + detected = agent.AgentTypeCursor + detectedCount++ + } + if _, err := tree.Tree(".factory"); err == nil { + detected = agent.AgentTypeFactoryAIDroid + detectedCount++ } + if detectedCount == 1 { + return detected + } return agent.AgentTypeUnknown } diff --git a/cmd/entire/cli/strategy/common_test.go b/cmd/entire/cli/strategy/common_test.go index 6a7c0cf90..c5aa4a2e4 100644 --- a/cmd/entire/cli/strategy/common_test.go +++ b/cmd/entire/cli/strategy/common_test.go @@ -8,6 +8,8 @@ import ( "sync" "testing" + "github.com/entireio/cli/cmd/entire/cli/agent" + _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/testutil" @@ -15,6 +17,8 @@ import ( "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestOpenRepository(t *testing.T) { @@ -1544,3 +1548,149 @@ func TestIsEmptyRepository(t *testing.T) { } }) } + +// openRepoHeadTree opens the repo at dir and returns the HEAD commit tree. +func openRepoHeadTree(t *testing.T, dir string) *object.Tree { + t.Helper() + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + head, err := repo.Head() + require.NoError(t, err) + commit, err := repo.CommitObject(head.Hash()) + require.NoError(t, err) + tree, err := commit.Tree() + require.NoError(t, err) + return tree +} + +func TestReadAgentTypeFromTree_OnlyClaude(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".claude/settings.json", `{}`) + testutil.GitAdd(t, dir, ".claude/settings.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeClaudeCode, result) +} + +func TestReadAgentTypeFromTree_OnlyGemini(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".gemini/settings.json", `{}`) + testutil.GitAdd(t, dir, ".gemini/settings.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeGemini, result) +} + +func TestReadAgentTypeFromTree_OnlyCodex(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".codex/config.json", `{}`) + testutil.GitAdd(t, dir, ".codex/config.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeCodex, result) +} + +func TestReadAgentTypeFromTree_OnlyCursor(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".cursor/settings.json", `{}`) + testutil.GitAdd(t, dir, ".cursor/settings.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeCursor, result) +} + +func TestReadAgentTypeFromTree_OnlyFactory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".factory/settings.json", `{}`) + testutil.GitAdd(t, dir, ".factory/settings.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeFactoryAIDroid, result) +} + +func TestReadAgentTypeFromTree_ClaudeAndCodex_ReturnsUnknown(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".claude/settings.json", `{}`) + testutil.GitAdd(t, dir, ".claude/settings.json") + testutil.WriteFile(t, dir, ".codex/config.json", `{}`) + testutil.GitAdd(t, dir, ".codex/config.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeUnknown, result) +} + +func TestReadAgentTypeFromTree_ClaudeAndGemini_ReturnsUnknown(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".claude/settings.json", `{}`) + testutil.GitAdd(t, dir, ".claude/settings.json") + testutil.WriteFile(t, dir, ".gemini/settings.json", `{}`) + testutil.GitAdd(t, dir, ".gemini/settings.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeUnknown, result) +} + +func TestReadAgentTypeFromTree_NoAgentDirs_ReturnsUnknown(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "f.txt", "init") + testutil.GitAdd(t, dir, "f.txt") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "nonexistent-path") + assert.Equal(t, agent.AgentTypeUnknown, result) +} + +func TestReadAgentTypeFromTree_MetadataJSON_OverridesDir(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, ".claude/settings.json", `{}`) + testutil.GitAdd(t, dir, ".claude/settings.json") + testutil.WriteFile(t, dir, "cp/metadata.json", `{"agent":"Cursor"}`) + testutil.GitAdd(t, dir, "cp/metadata.json") + testutil.GitCommit(t, dir, "init") + + tree := openRepoHeadTree(t, dir) + result := ReadAgentTypeFromTree(tree, "cp") + assert.Equal(t, agent.AgentTypeCursor, result) +}