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
9 changes: 9 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ type WriteCommittedOptions struct {

// Commit message fields (used for task checkpoints)
CommitSubject string // Subject line for the metadata commit (overrides default)

// Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor")
Agent string
}

// ReadCommittedResult contains the result of reading a committed checkpoint.
Expand Down Expand Up @@ -242,6 +245,9 @@ type CommittedInfo struct {
// FilesTouched are files modified during the session
FilesTouched []string

// Agent identifies the agent that created this checkpoint
Agent string

// IsTask indicates if this is a task checkpoint
IsTask bool

Expand All @@ -258,6 +264,9 @@ type CommittedMetadata struct {
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`

// Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor")
Agent string `json:"agent,omitempty"`

// Task checkpoint fields (only populated for task checkpoints)
IsTask bool `json:"is_task,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Expand Down
102 changes: 102 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package checkpoint

import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"entire.io/cli/cmd/entire/cli/paths"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)

Expand Down Expand Up @@ -80,3 +86,99 @@ func TestCopyMetadataDir_SkipsSymlinks(t *testing.T) {
t.Errorf("expected 1 entry, got %d", len(entries))
}
}

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

// Initialize a git repository with an initial commit
repo, err := git.PlainInit(tempDir, false)
if err != nil {
t.Fatalf("failed to init git repo: %v", err)
}

// Create worktree and make initial commit
worktree, err := repo.Worktree()
if err != nil {
t.Fatalf("failed to get worktree: %v", err)
}

readmeFile := filepath.Join(tempDir, "README.md")
if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
t.Fatalf("failed to write README: %v", err)
}
if _, err := worktree.Add("README.md"); err != nil {
t.Fatalf("failed to add README: %v", err)
}
if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
Author: &object.Signature{Name: "Test", Email: "test@test.com"},
}); err != nil {
t.Fatalf("failed to commit: %v", err)
}

// Create checkpoint store
store := NewGitStore(repo)

// Write a committed checkpoint with Agent field
checkpointID := "a1b2c3d4e5f6"
sessionID := "test-session-123"
agentName := "Claude Code"

err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: sessionID,
Strategy: "manual-commit",
Agent: agentName,
Transcript: []byte("test transcript content"),
AuthorName: "Test Author",
AuthorEmail: "test@example.com",
})
if err != nil {
t.Fatalf("WriteCommitted() error = %v", err)
}

// Verify metadata.json contains agent field
ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
if err != nil {
t.Fatalf("failed to get metadata branch reference: %v", err)
}

commit, err := repo.CommitObject(ref.Hash())
if err != nil {
t.Fatalf("failed to get commit object: %v", err)
}

tree, err := commit.Tree()
if err != nil {
t.Fatalf("failed to get tree: %v", err)
}

// Read metadata.json from the sharded path
shardedPath := paths.CheckpointPath(checkpointID)
metadataPath := shardedPath + "/" + paths.MetadataFileName
metadataFile, err := tree.File(metadataPath)
if err != nil {
t.Fatalf("failed to find metadata.json at %s: %v", metadataPath, err)
}

content, err := metadataFile.Contents()
if err != nil {
t.Fatalf("failed to read metadata.json: %v", err)
}

var metadata CommittedMetadata
if err := json.Unmarshal([]byte(content), &metadata); err != nil {
t.Fatalf("failed to parse metadata.json: %v", err)
}

if metadata.Agent != agentName {
t.Errorf("metadata.Agent = %q, want %q", metadata.Agent, agentName)
}

// Verify commit message contains Entire-Agent trailer
if !strings.Contains(commit.Message, paths.AgentTrailerKey+": "+agentName) {
t.Errorf("commit message should contain %s trailer with value %q, got:\n%s",
paths.AgentTrailerKey, agentName, commit.Message)
}
}
5 changes: 5 additions & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ func (s *GitStore) writeMetadataJSON(opts WriteCommittedOptions, basePath string
CreatedAt: time.Now(),
CheckpointsCount: opts.CheckpointsCount,
FilesTouched: opts.FilesTouched,
Agent: opts.Agent,
IsTask: opts.IsTask,
ToolUseID: opts.ToolUseID,
}
Expand Down Expand Up @@ -323,6 +324,9 @@ func (s *GitStore) buildCommitMessage(opts WriteCommittedOptions, taskMetadataPa
commitMsg.WriteString(fmt.Sprintf("Checkpoint: %s\n\n", opts.CheckpointID))
commitMsg.WriteString(fmt.Sprintf("%s: %s\n", paths.SessionTrailerKey, opts.SessionID))
commitMsg.WriteString(fmt.Sprintf("%s: %s\n", paths.StrategyTrailerKey, opts.Strategy))
if opts.Agent != "" {
commitMsg.WriteString(fmt.Sprintf("%s: %s\n", paths.AgentTrailerKey, opts.Agent))
}
if opts.EphemeralBranch != "" {
commitMsg.WriteString(fmt.Sprintf("%s: %s\n", paths.EphemeralBranchTrailerKey, opts.EphemeralBranch))
}
Expand Down Expand Up @@ -464,6 +468,7 @@ func (s *GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) {
info.CreatedAt = metadata.CreatedAt
info.CheckpointsCount = metadata.CheckpointsCount
info.FilesTouched = metadata.FilesTouched
info.Agent = metadata.Agent
info.IsTask = metadata.IsTask
info.ToolUseID = metadata.ToolUseID
}
Expand Down
28 changes: 28 additions & 0 deletions cmd/entire/cli/hooks_claudecode_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,12 @@ func commitWithMetadata() error {
fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err)
}

// Get agent type from session state (set during InitializeSession)
var agentType string
if sessionState != nil {
agentType = sessionState.AgentType
}

// Build fully-populated save context and delegate to strategy
ctx := strategy.SaveContext{
SessionID: entireSessionID,
Expand All @@ -486,6 +492,7 @@ func commitWithMetadata() error {
TranscriptPath: transcriptPath,
AuthorName: author.Name,
AuthorEmail: author.Email,
AgentType: agentType,
}

if err := strat.SaveChanges(ctx); err != nil {
Expand Down Expand Up @@ -615,6 +622,12 @@ func handlePostTodo() error {
// will fall back to "Checkpoint #N" format
}

// Get agent type from session state
var agentType string
if sessionState, loadErr := strategy.LoadSessionState(entireSessionID); loadErr == nil && sessionState != nil {
agentType = sessionState.AgentType
}

// Build incremental checkpoint context
ctx := strategy.TaskCheckpointContext{
SessionID: entireSessionID,
Expand All @@ -630,6 +643,7 @@ func handlePostTodo() error {
IncrementalType: input.ToolName,
IncrementalData: input.ToolInput,
TodoContent: todoContent,
AgentType: agentType,
}

// Save incremental checkpoint
Expand Down Expand Up @@ -710,6 +724,12 @@ func createStartingAgentCheckpoint(input *TaskHookInput) error {
// Extract subagent type and description from tool_input for descriptive commit messages
subagentType, taskDescription := ParseSubagentTypeAndDescription(input.ToolInput)

// Get agent type from session state
var agentType string
if sessionState, loadErr := strategy.LoadSessionState(entireSessionID); loadErr == nil && sessionState != nil {
agentType = sessionState.AgentType
}

// Build task checkpoint context for the "starting" checkpoint
ctx := strategy.TaskCheckpointContext{
SessionID: entireSessionID,
Expand All @@ -719,6 +739,7 @@ func createStartingAgentCheckpoint(input *TaskHookInput) error {
AuthorEmail: author.Email,
SubagentType: subagentType,
TaskDescription: taskDescription,
AgentType: agentType,
// No file changes yet - this is the starting state
ModifiedFiles: nil,
NewFiles: nil,
Expand Down Expand Up @@ -848,6 +869,12 @@ func handlePostTask() error {

entireSessionID := currentSessionIDWithFallback(input.SessionID)

// Get agent type from session state
var agentType string
if sessionState, loadErr := strategy.LoadSessionState(entireSessionID); loadErr == nil && sessionState != nil {
agentType = sessionState.AgentType
}

// Build task checkpoint context - strategy handles metadata creation
// Note: Incremental checkpoints are now created during task execution via handlePostTodo,
// so we don't need to collect/cleanup staging area here.
Expand All @@ -865,6 +892,7 @@ func handlePostTask() error {
AuthorEmail: author.Email,
SubagentType: subagentType,
TaskDescription: taskDescription,
AgentType: agentType,
}

// Call strategy to save task checkpoint - strategy handles all metadata creation
Expand Down
14 changes: 14 additions & 0 deletions cmd/entire/cli/integration_test/manual_commit_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
package integration

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"entire.io/cli/cmd/entire/cli/checkpoint"
"entire.io/cli/cmd/entire/cli/paths"
"entire.io/cli/cmd/entire/cli/strategy"
)
Expand Down Expand Up @@ -596,6 +598,18 @@ func TestShadow_TranscriptCondensation(t *testing.T) {
t.Errorf("content_hash.txt should exist at %s", hashPath)
}

// Verify metadata.json can be read and parsed (agent field is tested in unit tests)
metadataContent, found := env.ReadFileFromBranch("entire/sessions", metadataPath)
if !found {
t.Fatal("metadata.json should be readable")
}
var metadata checkpoint.CommittedMetadata
if err := json.Unmarshal([]byte(metadataContent), &metadata); err != nil {
t.Fatalf("failed to parse metadata.json: %v", err)
}
// Log agent value for debugging (may be empty in test environment due to agent detection)
t.Logf("Agent field in metadata.json: %q (empty is OK in test environment)", metadata.Agent)

// List all files in the checkpoint to help debug
t.Log("Files in entire/sessions:")
branches := env.ListBranchesWithPrefix("entire/sessions")
Expand Down
24 changes: 24 additions & 0 deletions cmd/entire/cli/integration_test/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,30 @@ func (env *TestEnv) ReadFileFromBranch(branchName, filePath string) (string, boo
return content, true
}

// GetLatestCommitMessageOnBranch returns the commit message of the latest commit on the given branch.
func (env *TestEnv) GetLatestCommitMessageOnBranch(branchName string) string {
env.T.Helper()

repo, err := git.PlainOpen(env.RepoDir)
if err != nil {
env.T.Fatalf("failed to open git repo: %v", err)
}

// Get the branch reference
ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
if err != nil {
env.T.Fatalf("failed to get branch %s reference: %v", branchName, err)
}

// Get the commit
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
env.T.Fatalf("failed to get commit object: %v", err)
}

return commit.Message
}

// GitCommitWithShadowHooks stages and commits files, simulating the prepare-commit-msg and post-commit hooks.
// This is used for testing manual-commit strategy which needs:
// - prepare-commit-msg hook: adds the Entire-Checkpoint trailer
Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ const CheckpointTrailerKey = "Entire-Checkpoint"
// Format: full branch name e.g. "entire/2b4c177"
const EphemeralBranchTrailerKey = "Ephemeral-branch"

// AgentTrailerKey identifies the agent that created a checkpoint.
// Format: human-readable agent name e.g. "Claude Code", "Cursor"
const AgentTrailerKey = "Entire-Agent"

// repoRootCache caches the repository root to avoid repeated git commands.
// The cache is keyed by the current working directory to handle directory changes.
var (
Expand Down
3 changes: 3 additions & 0 deletions cmd/entire/cli/strategy/auto_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(_ *git.Repository, c
MetadataDir: ctx.MetadataDirAbs, // Copy all files from metadata dir
AuthorName: ctx.AuthorName,
AuthorEmail: ctx.AuthorEmail,
Agent: ctx.AgentType,
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to write committed checkpoint: %w", err)
Expand Down Expand Up @@ -322,6 +323,7 @@ func (s *AutoCommitStrategy) GetRewindPoints(limit int) ([]RewindPoint, error) {
CheckpointID: checkpointID,
IsTaskCheckpoint: metadata.IsTask,
ToolUseID: metadata.ToolUseID,
Agent: metadata.Agent,
})

return nil
Expand Down Expand Up @@ -734,6 +736,7 @@ func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(_ *git.Repositor
CommitSubject: messageSubject,
AuthorName: ctx.AuthorName,
AuthorEmail: ctx.AuthorEmail,
Agent: ctx.AgentType,
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to write task checkpoint: %w", err)
Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func (s *ManualCommitStrategy) listCheckpoints() ([]CheckpointInfo, error) {
CreatedAt: c.CreatedAt,
CheckpointsCount: c.CheckpointsCount,
FilesTouched: c.FilesTouched,
Agent: c.Agent,
IsTask: c.IsTask,
ToolUseID: c.ToolUseID,
})
Expand Down Expand Up @@ -129,6 +130,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
EphemeralBranch: shadowBranchName,
AuthorName: authorName,
AuthorEmail: authorEmail,
Agent: state.AgentType,
}); err != nil {
return nil, fmt.Errorf("failed to write checkpoint metadata: %w", err)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/strategy/manual_commit_rewind.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ func (s *ManualCommitStrategy) GetLogsOnlyRewindPoints(limit int) ([]RewindPoint
Date: c.Author.When,
IsLogsOnly: true,
CheckpointID: cpInfo.CheckpointID,
Agent: cpInfo.Agent,
})

return nil
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/strategy/manual_commit_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type CheckpointInfo struct {
CreatedAt time.Time `json:"created_at"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
Agent string `json:"agent,omitempty"` // Human-readable agent name (e.g., "Claude Code")
IsTask bool `json:"is_task,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
}
Expand Down
Loading