Skip to content
Open
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
8 changes: 7 additions & 1 deletion cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ type WriteCommittedOptions struct {
// Branch is the branch name where the checkpoint was created (empty if detached HEAD)
Branch string

// CommitTreeHash is the git tree hash of HEAD at condensation time.
// Used as a content-addressable anchor for re-linking after history rewrites (rebase, squash).
CommitTreeHash string

// Transcript is the session transcript content (full.jsonl)
Transcript []byte

Expand Down Expand Up @@ -338,7 +342,8 @@ type CommittedMetadata struct {
SessionID string `json:"session_id"`
Strategy string `json:"strategy"`
CreatedAt time.Time `json:"created_at"`
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
Branch string `json:"branch,omitempty"` // Branch where checkpoint was created (empty if detached HEAD)
CommitTreeHash string `json:"commit_tree_hash,omitempty"` // Git tree hash of HEAD at condensation time
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`

Expand Down Expand Up @@ -416,6 +421,7 @@ type CheckpointSummary struct {
CheckpointID id.CheckpointID `json:"checkpoint_id"`
Strategy string `json:"strategy"`
Branch string `json:"branch,omitempty"`
CommitTreeHash string `json:"commit_tree_hash,omitempty"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
Sessions []SessionFilePaths `json:"sessions"`
Expand Down
111 changes: 111 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,117 @@ func TestWriteCommitted_BranchField(t *testing.T) {
})
}

// TestWriteCommitted_CommitTreeHashField verifies that the CommitTreeHash field
// is correctly stored in both session-level and root-level metadata.json.
func TestWriteCommitted_CommitTreeHashField(t *testing.T) {
t.Run("present", func(t *testing.T) {
repo, commitHash := setupBranchTestRepo(t)

// Get the tree hash of the initial commit
commit, err := repo.CommitObject(commitHash)
if err != nil {
t.Fatalf("failed to get commit: %v", err)
}
expectedTreeHash := commit.TreeHash.String()

checkpointID := id.MustCheckpointID("d1e2f3a4b5c6")
store := NewGitStore(repo)
err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: "test-session-treehash",
Strategy: "manual-commit",
CommitTreeHash: expectedTreeHash,
Transcript: []byte("test transcript content"),
AuthorName: "Test Author",
AuthorEmail: "test@example.com",
})
if err != nil {
t.Fatalf("WriteCommitted() error = %v", err)
}

// Verify in session-level metadata (CommittedMetadata)
metadata := readLatestSessionMetadata(t, repo, checkpointID)
if metadata.CommitTreeHash != expectedTreeHash {
t.Errorf("CommittedMetadata.CommitTreeHash = %q, want %q", metadata.CommitTreeHash, expectedTreeHash)
}

// Verify in root-level metadata (CheckpointSummary)
summary, err := store.ReadCommitted(context.Background(), checkpointID)
if err != nil {
t.Fatalf("ReadCommitted() error = %v", err)
}
if summary.CommitTreeHash != expectedTreeHash {
t.Errorf("CheckpointSummary.CommitTreeHash = %q, want %q", summary.CommitTreeHash, expectedTreeHash)
}
})

t.Run("omitted when empty", func(t *testing.T) {
repo, _ := setupBranchTestRepo(t)

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

// Verify field is empty
metadata := readLatestSessionMetadata(t, repo, checkpointID)
if metadata.CommitTreeHash != "" {
t.Errorf("CommittedMetadata.CommitTreeHash = %q, want empty", metadata.CommitTreeHash)
}

// Verify omitted from JSON (omitempty)
ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
if err != nil {
t.Fatalf("failed to get metadata branch: %v", err)
}
commitObj, err := repo.CommitObject(ref.Hash())
if err != nil {
t.Fatalf("failed to get commit: %v", err)
}
tree, err := commitObj.Tree()
if err != nil {
t.Fatalf("failed to get tree: %v", err)
}

// Check session-level metadata JSON
sessionPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
file, err := tree.File(sessionPath)
if err != nil {
t.Fatalf("failed to find session metadata: %v", err)
}
content, err := file.Contents()
if err != nil {
t.Fatalf("failed to read session metadata: %v", err)
}
if strings.Contains(content, `"commit_tree_hash"`) {
t.Errorf("session metadata should not contain 'commit_tree_hash' when empty (omitempty)")
}

// Check root-level metadata JSON
rootPath := checkpointID.Path() + "/" + paths.MetadataFileName
rootFile, err := tree.File(rootPath)
if err != nil {
t.Fatalf("failed to find root metadata: %v", err)
}
rootContent, err := rootFile.Contents()
if err != nil {
t.Fatalf("failed to read root metadata: %v", err)
}
if strings.Contains(rootContent, `"commit_tree_hash"`) {
t.Errorf("root metadata should not contain 'commit_tree_hash' when empty (omitempty)")
}
})
}

// TestUpdateSummary verifies that UpdateSummary correctly updates the summary
// field in an existing checkpoint's metadata.
func TestUpdateSummary(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ func (s *GitStore) writeSessionToSubdirectory(opts WriteCommittedOptions, sessio
Strategy: opts.Strategy,
CreatedAt: time.Now().UTC(),
Branch: opts.Branch,
CommitTreeHash: opts.CommitTreeHash,
CheckpointsCount: opts.CheckpointsCount,
FilesTouched: opts.FilesTouched,
Agent: opts.Agent,
Expand Down Expand Up @@ -383,6 +384,7 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s
CLIVersion: buildinfo.Version,
Strategy: opts.Strategy,
Branch: opts.Branch,
CommitTreeHash: opts.CommitTreeHash,
CheckpointsCount: checkpointsCount,
FilesTouched: filesTouched,
Sessions: sessions,
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/integration_test/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"

"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/strategy"
)

func TestHookRunner_SimulateUserPromptSubmit(t *testing.T) {
Expand Down
8 changes: 6 additions & 2 deletions cmd/entire/cli/strategy/auto_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,9 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository
// Extract session ID from metadata dir
sessionID := filepath.Base(ctx.MetadataDir)

// Get current branch name
// Get current branch name and tree hash
branchName := GetCurrentBranchName(repo)
commitTreeHash := GetHeadTreeHash(repo)

// Combine all file changes into FilesTouched (same as manual-commit)
filesTouched := mergeFilesTouched(nil, ctx.ModifiedFiles, ctx.NewFiles, ctx.DeletedFiles)
Expand All @@ -255,6 +256,7 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository
SessionID: sessionID,
Strategy: StrategyNameAutoCommit, // Use new strategy name
Branch: branchName,
CommitTreeHash: commitTreeHash,
MetadataDir: ctx.MetadataDirAbs, // Copy all files from metadata dir
AuthorName: ctx.AuthorName,
AuthorEmail: ctx.AuthorEmail,
Expand Down Expand Up @@ -643,15 +645,17 @@ func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(repo *git.Reposi
messageSubject = FormatSubagentEndMessage(ctx.SubagentType, ctx.TaskDescription, shortToolUseID)
}

// Get current branch name
// Get current branch name and tree hash
branchName := GetCurrentBranchName(repo)
commitTreeHash := GetHeadTreeHash(repo)

// Write committed checkpoint using the checkpoint store
err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: ctx.SessionID,
Strategy: StrategyNameAutoCommit,
Branch: branchName,
CommitTreeHash: commitTreeHash,
IsTask: true,
ToolUseID: ctx.ToolUseID,
AgentID: ctx.AgentID,
Expand Down
14 changes: 14 additions & 0 deletions cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,20 @@ func GetCurrentBranchName(repo *git.Repository) string {
return head.Name().Short()
}

// GetHeadTreeHash returns the hex string of HEAD's tree hash.
// Returns an empty string if HEAD or its commit cannot be read (e.g., empty repository).
func GetHeadTreeHash(repo *git.Repository) string {
head, err := repo.Head()
if err != nil {
return ""
}
commit, err := repo.CommitObject(head.Hash())
if err != nil {
return ""
}
return commit.TreeHash.String()
}

// getMainBranchHash returns the hash of the main branch (main or master).
// Returns ZeroHash if no main branch is found.
func GetMainBranchHash(repo *git.Repository) plumbing.Hash {
Expand Down
4 changes: 3 additions & 1 deletion cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
// Get author info
authorName, authorEmail := GetGitAuthorFromRepo(repo)
attribution := calculateSessionAttributions(repo, ref, sessionData, state)
// Get current branch name
// Get current branch name and tree hash
branchName := GetCurrentBranchName(repo)
commitTreeHash := GetHeadTreeHash(repo)

// Generate summary if enabled
var summary *cpkg.Summary
Expand Down Expand Up @@ -179,6 +180,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
SessionID: state.SessionID,
Strategy: StrategyNameManualCommit,
Branch: branchName,
CommitTreeHash: commitTreeHash,
Transcript: sessionData.Transcript,
Prompts: sessionData.Prompts,
Context: sessionData.Context,
Expand Down
Loading