diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index e79fe83bf..9b28d1350 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -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 @@ -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"` @@ -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"` diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index 0e73ca190..51937b89e 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -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) { diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 67cf6947c..4e5cc0b6a 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -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, @@ -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, diff --git a/cmd/entire/cli/integration_test/hooks_test.go b/cmd/entire/cli/integration_test/hooks_test.go index e732100c8..b38c2ffe6 100644 --- a/cmd/entire/cli/integration_test/hooks_test.go +++ b/cmd/entire/cli/integration_test/hooks_test.go @@ -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) { diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index ddc73607a..9c62b567c 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -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) @@ -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, @@ -643,8 +645,9 @@ 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{ @@ -652,6 +655,7 @@ func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(repo *git.Reposi SessionID: ctx.SessionID, Strategy: StrategyNameAutoCommit, Branch: branchName, + CommitTreeHash: commitTreeHash, IsTask: true, ToolUseID: ctx.ToolUseID, AgentID: ctx.AgentID, diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index d815be17a..7a7aa91a0 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -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 { diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 6f7475694..381af50ac 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -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 @@ -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, diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 07aec19c6..402a0d8b5 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -2124,6 +2124,165 @@ func TestCondenseSession_IncludesInitialAttribution(t *testing.T) { metadata.InitialAttribution.AgentPercentage) } +// TestCondenseSession_IncludesCommitTreeHash verifies that condensation stores the +// commit_tree_hash field in both session-level and root-level metadata. +func TestCondenseSession_IncludesCommitTreeHash(t *testing.T) { + dir := t.TempDir() + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + // Create initial commit + testFile := filepath.Join(dir, "main.go") + if err := os.WriteFile(testFile, []byte("package main\n"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if _, err := worktree.Add("main.go"); err != nil { + t.Fatalf("failed to stage file: %v", err) + } + _, err = worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com", When: time.Now()}, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + t.Chdir(dir) + + s := &ManualCommitStrategy{} + sessionID := "2025-01-15-test-treehash" + + // Create metadata directory with transcript + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + if err := os.MkdirAll(metadataDirAbs, 0o755); err != nil { + t.Fatalf("failed to create metadata dir: %v", err) + } + transcript := `{"type":"human","message":{"content":"add a function"}} +{"type":"assistant","message":{"content":"Done"}} +` + if err := os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + // Agent modifies the file + if err := os.WriteFile(testFile, []byte("package main\n\nfunc hello() {}\n"), 0o644); err != nil { + t.Fatalf("failed to write agent changes: %v", err) + } + + // Save a checkpoint + err = s.SaveChanges(SaveContext{ + SessionID: sessionID, + ModifiedFiles: []string{"main.go"}, + NewFiles: []string{}, + DeletedFiles: []string{}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "Checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + if err != nil { + t.Fatalf("SaveChanges() error = %v", err) + } + + // User commits + if _, err := worktree.Add("main.go"); err != nil { + t.Fatalf("failed to stage: %v", err) + } + _, err = worktree.Commit("User commit", &git.CommitOptions{ + Author: &object.Signature{Name: "User", Email: "user@test.com", When: time.Now()}, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + // Get HEAD's tree hash (what condense should capture) + head, err := repo.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + headCommit, err := repo.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("failed to get HEAD commit: %v", err) + } + expectedTreeHash := headCommit.TreeHash.String() + + // Load session state and condense + state, err := s.loadSessionState(sessionID) + if err != nil { + t.Fatalf("loadSessionState() error = %v", err) + } + + checkpointID := id.MustCheckpointID("c3d4e5f6a7b8") + _, err = s.CondenseSession(repo, checkpointID, state) + if err != nil { + t.Fatalf("CondenseSession() error = %v", err) + } + + // Read metadata from entire/checkpoints/v1 branch + sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + if err != nil { + t.Fatalf("failed to get sessions branch: %v", err) + } + sessionsCommit, err := repo.CommitObject(sessionsRef.Hash()) + if err != nil { + t.Fatalf("failed to get sessions commit: %v", err) + } + tree, err := sessionsCommit.Tree() + if err != nil { + t.Fatalf("failed to get tree: %v", err) + } + + // Verify commit_tree_hash in session-level metadata (0/metadata.json) + sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName + metadataFile, err := tree.File(sessionMetadataPath) + if err != nil { + t.Fatalf("failed to find session metadata at %s: %v", sessionMetadataPath, err) + } + content, err := metadataFile.Contents() + if err != nil { + t.Fatalf("failed to read session metadata: %v", err) + } + + var sessionMeta struct { + CommitTreeHash string `json:"commit_tree_hash"` + } + if err := json.Unmarshal([]byte(content), &sessionMeta); err != nil { + t.Fatalf("failed to parse session metadata: %v", err) + } + if sessionMeta.CommitTreeHash != expectedTreeHash { + t.Errorf("session metadata commit_tree_hash = %q, want %q", sessionMeta.CommitTreeHash, expectedTreeHash) + } + + // Verify commit_tree_hash in root-level summary (metadata.json) + rootMetadataPath := checkpointID.Path() + "/" + paths.MetadataFileName + rootFile, err := tree.File(rootMetadataPath) + if err != nil { + t.Fatalf("failed to find root metadata at %s: %v", rootMetadataPath, err) + } + rootContent, err := rootFile.Contents() + if err != nil { + t.Fatalf("failed to read root metadata: %v", err) + } + + var rootMeta struct { + CommitTreeHash string `json:"commit_tree_hash"` + } + if err := json.Unmarshal([]byte(rootContent), &rootMeta); err != nil { + t.Fatalf("failed to parse root metadata: %v", err) + } + if rootMeta.CommitTreeHash != expectedTreeHash { + t.Errorf("root metadata commit_tree_hash = %q, want %q", rootMeta.CommitTreeHash, expectedTreeHash) + } +} + // TestExtractUserPromptsFromLines tests extraction of user prompts from JSONL format. func TestExtractUserPromptsFromLines(t *testing.T) { tests := []struct {