Skip to content
Closed
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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ Legacy names `shadow` and `dual` are only recognized when reading settings or ch

**Note:** Both strategies keep active branch history **clean**. Manual-commit strategy never creates commits on the active branch. Auto-commit strategy creates commits with only the `Entire-Checkpoint` trailer. All detailed metadata is stored on the `entire/sessions` orphan branch or shadow branches.

#### Why Checkpoint ID is the Stable Link (Not Commit Hash)

**Important architectural constraint:** Never store commit hashes as primary links to session data. Commit hashes are mutable - they change during `git commit --amend`, `git rebase`, and `git cherry-pick`.

The `Entire-Checkpoint: <checkpoint-id>` trailer is the **stable link** because:
1. It's embedded in the commit message content
2. It travels with the commit through history-rewriting operations
3. The checkpoint ID (12-hex-char) is immutable once generated

**To find a commit for a checkpoint:** Search git history for commits containing the `Entire-Checkpoint: <id>` trailer, don't store/cache the commit hash.

**Example:** To display the commit message for a session in `entire session list`, search for commits with matching `Entire-Checkpoint` trailer rather than storing the commit hash in metadata.

#### When Modifying Strategies
- All strategies must implement the full `Strategy` interface
- Register new strategies in `init()` using `Register()`
Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ type WriteCommittedOptions struct {
// CheckpointsCount is the number of checkpoints in this session
CheckpointsCount int

// Branch is the branch where the code commit lives (used for finding commit message)
Branch string

// EphemeralBranch is the shadow branch name (for manual-commit strategy)
EphemeralBranch string

Expand Down Expand Up @@ -257,6 +260,7 @@ type CommittedMetadata struct {
CreatedAt time.Time `json:"created_at"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
Branch string `json:"branch,omitempty"` // Branch where the code commit lives

// Task checkpoint fields (only populated for task checkpoints)
IsTask bool `json:"is_task,omitempty"`
Expand Down
1 change: 1 addition & 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,
Branch: opts.Branch,
IsTask: opts.IsTask,
ToolUseID: opts.ToolUseID,
}
Expand Down
19 changes: 14 additions & 5 deletions cmd/entire/cli/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,8 +330,8 @@ func runSessionList() error {
}

// Print header (2-space indent to align with marker column)
fmt.Printf(" %-19s %-11s %s\n", "session-id", "Checkpoints", "Description")
fmt.Printf(" %-19s %-11s %s\n", "───────────────────", "───────────", "────────────────────────────────────────────────────────────────")
fmt.Printf(" %-19s %-11s %-30s %s\n", "session-id", "Checkpoints", "Branch", "Description")
fmt.Printf(" %-19s %-11s %-30s %s\n", "───────────────────", "───────────", "──────────────────────────────", "────────────────────────────────────────")

for _, sess := range sessions {
// Show session ID - truncate to 19 chars for display
Expand All @@ -350,13 +350,22 @@ func runSessionList() error {
// Checkpoint count
checkpoints := len(sess.Checkpoints)

// Branch name - truncate if needed
branch := sess.Branch
if branch == "" {
branch = "-"
}
if len(branch) > 30 {
branch = branch[:27] + "..."
}

// Truncate description for display
description := sess.Description
if len(description) > 64 {
description = description[:61] + "..."
if len(description) > 44 {
description = description[:41] + "..."
}

fmt.Printf("%s%-19s %-11d %s\n", marker, displayID, checkpoints, description)
fmt.Printf("%s%-19s %-11d %-30s %s\n", marker, displayID, checkpoints, branch, description)
}

// Print usage hint
Expand Down
12 changes: 10 additions & 2 deletions cmd/entire/cli/strategy/auto_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx SaveCo
// Metadata is stored at sharded path: <checkpointID[:2]>/<checkpointID[2:]>/
// This allows direct lookup from the checkpoint ID trailer on the code commit.
// Uses checkpoint.WriteCommitted for git operations.
func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(_ *git.Repository, ctx SaveContext, checkpointID string) (plumbing.Hash, error) {
func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository, ctx SaveContext, checkpointID string) (plumbing.Hash, error) {
store, err := s.getCheckpointStore()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get checkpoint store: %w", err)
Expand All @@ -222,12 +222,16 @@ func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(_ *git.Repository, c
// Extract session ID from metadata dir
sessionID := filepath.Base(ctx.MetadataDir)

// Get current branch name for commit lookup hint
branchName := getCurrentBranchName(repo)

// Write committed checkpoint using the checkpoint store
err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: sessionID,
Strategy: StrategyNameAutoCommit, // Use new strategy name
MetadataDir: ctx.MetadataDirAbs, // Copy all files from metadata dir
Branch: branchName,
AuthorName: ctx.AuthorName,
AuthorEmail: ctx.AuthorEmail,
})
Expand Down Expand Up @@ -689,7 +693,7 @@ func (s *AutoCommitStrategy) commitTaskCodeToActive(repo *git.Repository, ctx Ta
// Returns the metadata commit hash.
// When IsIncremental is true, only writes the incremental checkpoint file, skipping transcripts.
// Uses checkpoint.WriteCommitted for git operations.
func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(_ *git.Repository, ctx TaskCheckpointContext, checkpointID string) (plumbing.Hash, error) {
func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(repo *git.Repository, ctx TaskCheckpointContext, checkpointID string) (plumbing.Hash, error) {
store, err := s.getCheckpointStore()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get checkpoint store: %w", err)
Expand All @@ -715,6 +719,9 @@ func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(_ *git.Repositor
messageSubject = FormatSubagentEndMessage(ctx.SubagentType, ctx.TaskDescription, shortToolUseID)
}

// Get current branch name for commit lookup hint
branchName := getCurrentBranchName(repo)

// Write committed checkpoint using the checkpoint store
err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{
CheckpointID: checkpointID,
Expand All @@ -731,6 +738,7 @@ func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(_ *git.Repositor
IncrementalType: ctx.IncrementalType,
IncrementalData: ctx.IncrementalData,
CommitSubject: messageSubject,
Branch: branchName,
AuthorName: ctx.AuthorName,
AuthorEmail: ctx.AuthorEmail,
})
Expand Down
13 changes: 9 additions & 4 deletions cmd/entire/cli/strategy/auto_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -670,21 +670,26 @@ func TestAutoCommitStrategy_ListSessions_HasDescription(t *testing.T) {
t.Fatalf("failed to write log file: %v", err)
}

// Write prompt.txt with description
expectedDescription := "Fix the authentication bug in login.go"
// Write prompt.txt with description (this is the fallback if no commit is found)
promptDescription := "Fix the authentication bug in login.go"
promptFile := filepath.Join(metadataDir, paths.PromptFileName)
if err := os.WriteFile(promptFile, []byte(expectedDescription+"\n\nMore details here..."), 0o644); err != nil {
if err := os.WriteFile(promptFile, []byte(promptDescription+"\n\nMore details here..."), 0o644); err != nil {
t.Fatalf("failed to write prompt file: %v", err)
}

// The expected description is the commit message, not prompt.txt
// Since we now prefer commit messages over prompt.txt
expectedDescription := "Implement user authentication feature"

metadataDirAbs, err := paths.AbsPath(metadataDir)
if err != nil {
metadataDirAbs = metadataDir
}

// Save changes - this creates a checkpoint on entire/sessions
// The commit message is what will be shown as the session description
ctx := SaveContext{
CommitMessage: "Test checkpoint",
CommitMessage: expectedDescription,
MetadataDir: metadataDir,
MetadataDirAbs: metadataDirAbs,
NewFiles: []string{"test.go"},
Expand Down
15 changes: 15 additions & 0 deletions cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -1107,3 +1107,18 @@ func GetMainBranchHash(repo *git.Repository) plumbing.Hash {
}
return plumbing.ZeroHash
}

// getCurrentBranchName returns the current branch name, or empty string if not on a branch (detached HEAD).
// Returns short name like "main" or "feature/add-auth", not the full ref path.
func getCurrentBranchName(repo *git.Repository) string {
head, err := repo.Head()
if err != nil {
return ""
}

if !head.Name().IsBranch() {
return "" // Detached HEAD
}

return head.Name().Short()
}
4 changes: 4 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
// Get author info
authorName, authorEmail := GetGitAuthorFromRepo(repo)

// Get current branch name for commit lookup hint
branchName := getCurrentBranchName(repo)

// Write checkpoint metadata using the checkpoint store
if err := store.WriteCommitted(context.Background(), cpkg.WriteCommittedOptions{
CheckpointID: checkpointID,
Expand All @@ -126,6 +129,7 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
Context: sessionData.Context,
FilesTouched: sessionData.FilesTouched,
CheckpointsCount: state.CheckpointCount,
Branch: branchName,
EphemeralBranch: shadowBranchName,
AuthorName: authorName,
AuthorEmail: authorEmail,
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 @@ -35,6 +35,7 @@ type CheckpointInfo struct {
CreatedAt time.Time `json:"created_at"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
Branch string `json:"branch,omitempty"` // Branch where the code commit lives (hint for finding commit message)
IsTask bool `json:"is_task,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
}
Expand Down
141 changes: 133 additions & 8 deletions cmd/entire/cli/strategy/session.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package strategy

import (
"errors"
"fmt"
"sort"
"strings"
"time"

"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"
)

// NoDescription is the default description for sessions without one.
Expand All @@ -24,6 +27,9 @@ type Session struct {
// (typically the first prompt or derived from commit messages)
Description string

// Branch is the branch where the session's code commit lives
Branch string

// Strategy is the name of the strategy that created this session
Strategy string

Expand Down Expand Up @@ -106,12 +112,32 @@ func ListSessions() ([]Session, error) {
ToolUseID: cp.ToolUseID,
})
} else {
// Get description from the checkpoint tree
description := getDescriptionForCheckpoint(repo, cp.CheckpointID)
// Get description and determine current location
description, foundInHead := findCommitMessageByCheckpointID(repo, cp.CheckpointID, cp.CreatedAt, cp.Branch)
if description == "" {
// Fall back to reading from entire/sessions branch
tree, err := GetMetadataBranchTree(repo)
if err == nil {
checkpointPath := paths.CheckpointPath(cp.CheckpointID)
description = getSessionDescriptionFromTree(tree, checkpointPath)
} else {
description = NoDescription
}
}

// Determine display branch: show current location if different from original
displayBranch := cp.Branch
if foundInHead && cp.Branch != "" {
currentBranch := getCurrentBranchName(repo)
if currentBranch != "" && currentBranch != cp.Branch {
displayBranch = formatBranchLocation(currentBranch, cp.Branch)
}
}

sessionMap[cp.SessionID] = &Session{
ID: cp.SessionID,
Description: description,
Branch: displayBranch,
Strategy: "", // Will be set from metadata if available
StartTime: cp.CreatedAt,
Checkpoints: []Checkpoint{{
Expand Down Expand Up @@ -197,15 +223,114 @@ func GetSession(sessionID string) (*Session, error) {
return findSessionByID(sessions, sessionID)
}

// getDescriptionForCheckpoint reads the description for a checkpoint from the entire/sessions branch.
func getDescriptionForCheckpoint(repo *git.Repository, checkpointID string) string {
tree, err := GetMetadataBranchTree(repo)
// findCommitMessageByCheckpointID searches for a commit with the given Entire-Checkpoint trailer
// and returns the first line of its commit message, plus whether it was found in HEAD.
// The search window is from (metadataCreatedAt - 5 minutes) to now, since:
// - The original commit was created around metadataCreatedAt
// - Rebase/amend can make the commit newer than the metadata timestamp
// - We don't need to look at commits older than a few minutes before the metadata
// Searches HEAD first (to find merged commits), then falls back to branchHint (for unmerged PRs).
// Returns (message, foundInHead).
func findCommitMessageByCheckpointID(repo *git.Repository, checkpointID string, metadataCreatedAt time.Time, branchHint string) (string, bool) {
// Search window: commits from 5 minutes before metadata timestamp to now
// The commit can't be much older than the metadata, but can be arbitrarily newer (after rebase)
searchLowerBound := metadataCreatedAt.Add(-5 * time.Minute)

// First, search from HEAD (finds merged commits or commits on current branch)
head, err := repo.Head()
if err == nil {
if msg := searchCommitsForCheckpoint(repo, head.Hash(), checkpointID, searchLowerBound); msg != "" {
return msg, true // Found in HEAD
}
}

// Fall back to searching the branch hint (for unmerged PRs or if we're on a different branch)
if branchHint != "" {
if msg := searchBranchForCheckpoint(repo, branchHint, checkpointID, searchLowerBound); msg != "" {
return msg, false // Found in branch hint, not in HEAD
}
}

return "", false
}

// searchBranchForCheckpoint searches a specific branch for a checkpoint.
// Tries local branch first, then falls back to remote tracking branch (origin/<branch>).
func searchBranchForCheckpoint(repo *git.Repository, branchName, checkpointID string, searchLowerBound time.Time) string {
// Try to resolve the local branch first
ref, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true)
if err != nil {
// Local branch doesn't exist, try remote tracking branch
ref, err = repo.Reference(plumbing.NewRemoteReferenceName("origin", branchName), true)
if err != nil {
return "" // Neither local nor remote branch exists
}
}

return searchCommitsForCheckpoint(repo, ref.Hash(), checkpointID, searchLowerBound)
}

// searchCommitsForCheckpoint searches commits starting from a given hash.
func searchCommitsForCheckpoint(repo *git.Repository, startHash plumbing.Hash, checkpointID string, searchLowerBound time.Time) string {
iter, err := repo.Log(&git.LogOptions{
From: startHash,
Order: git.LogOrderCommitterTime,
})
if err != nil {
return NoDescription
return ""
}
defer iter.Close()

var foundMsg string
maxCommitsToScan := 500 // Safety limit

//nolint:errcheck,gosec // ForEach error handling via sentinel
iter.ForEach(func(c *object.Commit) error {
maxCommitsToScan--
if maxCommitsToScan <= 0 {
return errors.New("limit reached")
}

// Stop if commit is older than our search window
if c.Committer.When.Before(searchLowerBound) {
return errors.New("too old")
}

// Check if this commit has the matching Entire-Checkpoint trailer
if cpID, found := paths.ParseCheckpointTrailer(c.Message); found && cpID == checkpointID {
// Found the commit - extract first line of message
foundMsg = extractCommitSubject(c.Message)
return errors.New("found")
}

return nil
})

return foundMsg
}

// extractCommitSubject returns the first line of a commit message,
// excluding any trailing Entire-Checkpoint trailer if it's on the first line.
func extractCommitSubject(message string) string {
lines := strings.SplitN(message, "\n", 2)
if len(lines) == 0 {
return ""
}

checkpointPath := paths.CheckpointPath(checkpointID)
return getSessionDescriptionFromTree(tree, checkpointPath)
subject := strings.TrimSpace(lines[0])

// If the subject line is just the trailer, return empty (let fallback handle it)
if strings.HasPrefix(subject, paths.CheckpointTrailerKey+":") {
return ""
}

return subject
}

// formatBranchLocation formats a branch location string showing current and original branches.
// Returns "current (was original)" format, e.g., "main (was feature/add-auth)".
func formatBranchLocation(currentBranch, originalBranch string) string {
return fmt.Sprintf("%s (was %s)", currentBranch, originalBranch)
}

// findSessionByID finds a session by exact ID or prefix match.
Expand Down