diff --git a/CLAUDE.md b/CLAUDE.md index 7c2fed8185..2affcf502b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: ` 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: ` 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()` diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index d4ffb363b6..0ad9d9190c 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -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 @@ -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"` diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 278c762fae..472eb57546 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -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, } diff --git a/cmd/entire/cli/session.go b/cmd/entire/cli/session.go index 5b2fd8c444..6d74219f33 100644 --- a/cmd/entire/cli/session.go +++ b/cmd/entire/cli/session.go @@ -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 @@ -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 diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index d696071f20..98f1a6258f 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -213,7 +213,7 @@ func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx SaveCo // Metadata is stored at sharded path: // // 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) @@ -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, }) @@ -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) @@ -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, @@ -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, }) diff --git a/cmd/entire/cli/strategy/auto_commit_test.go b/cmd/entire/cli/strategy/auto_commit_test.go index 97e14a2f7c..3bcde2cd61 100644 --- a/cmd/entire/cli/strategy/auto_commit_test.go +++ b/cmd/entire/cli/strategy/auto_commit_test.go @@ -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"}, diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 5d0aef589c..fb5bee63db 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -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() +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 301007f250..778c5a45e5 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -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, @@ -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, diff --git a/cmd/entire/cli/strategy/manual_commit_types.go b/cmd/entire/cli/strategy/manual_commit_types.go index e65c9834f9..f999285bb2 100644 --- a/cmd/entire/cli/strategy/manual_commit_types.go +++ b/cmd/entire/cli/strategy/manual_commit_types.go @@ -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"` } diff --git a/cmd/entire/cli/strategy/session.go b/cmd/entire/cli/strategy/session.go index c44f7dbbd0..dcbf0caa42 100644 --- a/cmd/entire/cli/strategy/session.go +++ b/cmd/entire/cli/strategy/session.go @@ -1,6 +1,7 @@ package strategy import ( + "errors" "fmt" "sort" "strings" @@ -8,6 +9,8 @@ import ( "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. @@ -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 @@ -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{{ @@ -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/). +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.