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
38 changes: 28 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ Integration tests use the `//go:build integration` build tag and are located in
mise run fmt && mise run lint
```

### Before Every Commit (REQUIRED)

**CI will fail if you skip these steps:**

```bash
mise run fmt # Format code (CI enforces gofmt)
mise run lint # Lint check (CI enforces golangci-lint)
mise run test:ci # Run all tests (unit + integration)
```

Or combined: `mise run fmt && mise run lint && mise run test:ci`

**Common CI failures from skipping this:**
- `gofmt` formatting differences → run `mise run fmt`
- Lint errors → run `mise run lint` and fix issues
- Test failures → run `mise run test` and fix

### Code Duplication Prevention

Before implementing Go code, use `/go:discover-related` to find existing utilities and patterns that might be reusable.
Expand Down Expand Up @@ -189,15 +206,16 @@ All strategies implement:

| Strategy | Main Branch | Metadata Storage | Use Case |
|----------|-------------|------------------|----------|
| **manual-commit** (default) | Unchanged (no commits) | `entire/<HEAD-hash>` branches + `entire/sessions` | Recommended for most workflows |
| **manual-commit** (default) | Unchanged (no commits) | `entire/<HEAD-hash>-<worktreeHash>` branches + `entire/sessions` | Recommended for most workflows |
| **auto-commit** | Creates clean commits | Orphan `entire/sessions` branch | Teams that want code commits from sessions |

#### Strategy Details

**Manual-Commit Strategy** (`manual_commit*.go`) - Default
- **Does not modify** the active branch - no commits created on the working branch
- Creates shadow branch `entire/<HEAD-commit-hash[:7]>` per base commit for checkpoints
- **Supports multiple concurrent sessions** - checkpoints from different sessions interleave on the same shadow branch
- Creates shadow branch `entire/<HEAD-commit-hash[:7]>-<worktreeHash[:6]>` per base commit + worktree
- **Worktree-specific branches** - each git worktree gets its own shadow branch namespace, preventing conflicts
- **Supports multiple concurrent sessions** - checkpoints from different sessions in the same directory interleave on the same shadow branch
- Session logs are condensed to permanent `entire/sessions` branch on user commits
- Builds git trees in-memory using go-git plumbing APIs
- Rewind restores files from shadow branch commit tree (does not use `git reset`)
Expand Down Expand Up @@ -248,7 +266,7 @@ All strategies implement:

#### Metadata Structure

**Shadow Strategy** - Shadow branches (`entire/<commit-hash>`):
**Shadow Strategy** - Shadow branches (`entire/<commit-hash[:7]>-<worktreeHash[:6]>`):
```
.entire/metadata/<session-id>/
├── full.jsonl # Session transcript
Expand Down Expand Up @@ -362,7 +380,7 @@ entire/sessions commit:
- Auto-commit: Always added when creating commits
- Manual-commit: Added by hook; user can remove to skip linking

**On shadow branch commits (`entire/<commit-hash>`) - manual-commit only:**
**On shadow branch commits (`entire/<commit-hash[:7]>-<worktreeHash[:6]>`) - manual-commit only:**
- `Entire-Session: <session-id>` - Session identifier
- `Entire-Metadata: <path>` - Path to metadata directory within the tree
- `Entire-Task-Metadata: <path>` - Path to task metadata directory (for task checkpoints)
Expand All @@ -384,22 +402,23 @@ Trailers:
#### Multi-Session Behavior

**Concurrent Sessions:**
- When a second session starts while another has uncommitted checkpoints, a warning is shown
- When a second session starts in the same directory while another has uncommitted checkpoints, a warning is shown
- Both sessions can proceed - their checkpoints interleave on the same shadow branch
- Each session's `RewindPoint` includes `SessionID` and `SessionPrompt` to help identify which checkpoint belongs to which session
- On commit, all sessions are condensed together with archived sessions in numbered subfolders
- Note: Different git worktrees have separate shadow branches (worktree-specific naming), so concurrent sessions in different worktrees do not conflict

**Orphaned Shadow Branches:**
- A shadow branch is "orphaned" if it exists but has no corresponding session state file
- This can happen if the state file is manually deleted or lost
- When a new session starts with an orphaned branch, the branch is automatically reset
- If the existing session DOES have a state file (e.g., cross-worktree conflict), a `SessionIDConflictError` is returned
- If the existing session DOES have a state file (concurrent session in same directory), a `SessionIDConflictError` is returned

**Shadow Branch Migration (Pull/Rebase):**
- If user does stash → pull → apply (or rebase), HEAD changes but work isn't committed
- The shadow branch would be orphaned at the old commit
- Detection: base commit changed AND old shadow branch still exists (would be deleted if user committed)
- Action: shadow branch is renamed from `entire/<old-hash>` to `entire/<new-hash>`
- Action: shadow branch is renamed from `entire/<old-hash>-<worktreeHash>` to `entire/<new-hash>-<worktreeHash>`
- Session continues seamlessly with checkpoints preserved

#### When Modifying Strategies
Expand All @@ -410,9 +429,8 @@ Trailers:

# Important Notes

- Tests: always run `mise run test` before committing changes
- **Before committing:** Follow the "Before Every Commit (REQUIRED)" checklist above - CI will fail without it
- Integration tests: run `mise run test:integration` when changing integration test code
- Formatting and linting: always run `mise run fmt && mise run lint` before committing changes
- When adding new features, ensure they are well-tested and documented.
- Always check for code duplication and refactor as needed.

Expand Down
11 changes: 10 additions & 1 deletion cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ type Store interface {

// ReadTemporary reads the latest checkpoint from a shadow branch.
// baseCommit is the commit hash the session is based on.
// worktreeID is the internal git worktree identifier (empty for main worktree).
// Returns nil, nil if the shadow branch doesn't exist.
ReadTemporary(ctx context.Context, baseCommit string) (*ReadTemporaryResult, error)
ReadTemporary(ctx context.Context, baseCommit, worktreeID string) (*ReadTemporaryResult, error)

// ListTemporary lists all shadow branches with their checkpoint info.
ListTemporary(ctx context.Context) ([]TemporaryInfo, error)
Expand Down Expand Up @@ -109,6 +110,10 @@ type WriteTemporaryOptions struct {
// BaseCommit is the commit hash this session is based on
BaseCommit string

// WorktreeID is the internal git worktree identifier (empty for main worktree)
// Used to create worktree-specific shadow branch names
WorktreeID string

// ModifiedFiles are files that have been modified (relative paths)
ModifiedFiles []string

Expand Down Expand Up @@ -432,6 +437,10 @@ type WriteTemporaryTaskOptions struct {
// BaseCommit is the commit hash this session is based on
BaseCommit string

// WorktreeID is the internal git worktree identifier (empty for main worktree)
// Used to create worktree-specific shadow branch names
WorktreeID string

// ToolUseID is the unique identifier for this Task tool invocation
ToolUseID string

Expand Down
95 changes: 74 additions & 21 deletions cmd/entire/cli/checkpoint/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package checkpoint

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -33,11 +35,21 @@ const (
// Shadow branches are named "entire/<hash>" using the first 7 characters of the commit hash.
ShadowBranchHashLength = 7

// WorktreeIDHashLength is the number of hex characters used for worktree ID hash.
WorktreeIDHashLength = 6

// gitDir and entireDir are excluded from tree operations.
gitDir = ".git"
entireDir = ".entire"
)

// HashWorktreeID returns a short hash of the worktree identifier.
// Used to create unique shadow branch names per worktree.
func HashWorktreeID(worktreeID string) string {
h := sha256.Sum256([]byte(worktreeID))
return hex.EncodeToString(h[:])[:WorktreeIDHashLength]
}

// WriteTemporary writes a temporary checkpoint to a shadow branch.
// Shadow branches are named entire/<base-commit-short-hash>.
// Returns the result containing commit hash and whether it was skipped.
Expand All @@ -57,7 +69,7 @@ func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOption
}

// Get shadow branch name
shadowBranchName := ShadowBranchNameForCommit(opts.BaseCommit)
shadowBranchName := ShadowBranchNameForCommit(opts.BaseCommit, opts.WorktreeID)

// Get or create shadow branch
parentHash, baseTreeHash, err := s.getOrCreateShadowBranch(shadowBranchName)
Expand Down Expand Up @@ -126,10 +138,11 @@ func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOption

// ReadTemporary reads the latest checkpoint from a shadow branch.
// Returns nil if the shadow branch doesn't exist.
func (s *GitStore) ReadTemporary(ctx context.Context, baseCommit string) (*ReadTemporaryResult, error) {
// worktreeID should be empty for main worktree or the internal git worktree name for linked worktrees.
func (s *GitStore) ReadTemporary(ctx context.Context, baseCommit, worktreeID string) (*ReadTemporaryResult, error) {
_ = ctx // Reserved for future use

shadowBranchName := ShadowBranchNameForCommit(baseCommit)
shadowBranchName := ShadowBranchNameForCommit(baseCommit, worktreeID)
refName := plumbing.NewBranchReferenceName(shadowBranchName)

ref, err := s.repo.Reference(refName, true)
Expand Down Expand Up @@ -184,8 +197,8 @@ func (s *GitStore) ListTemporary(ctx context.Context) ([]TemporaryInfo, error) {

sessionID, _ := trailers.ParseSession(commit.Message)

// Extract base commit from branch name
baseCommit := strings.TrimPrefix(branchName, ShadowBranchPrefix)
// Extract base commit from branch name (handles new "entire/<commit>-<worktreeHash>" format)
baseCommit, _, _ := ParseShadowBranchName(branchName)

results = append(results, TemporaryInfo{
BranchName: branchName,
Expand Down Expand Up @@ -228,7 +241,7 @@ func (s *GitStore) WriteTemporaryTask(ctx context.Context, opts WriteTemporaryTa
}

// Get shadow branch name
shadowBranchName := ShadowBranchNameForCommit(opts.BaseCommit)
shadowBranchName := ShadowBranchNameForCommit(opts.BaseCommit, opts.WorktreeID)

// Get or create shadow branch
parentHash, baseTreeHash, err := s.getOrCreateShadowBranch(shadowBranchName)
Expand Down Expand Up @@ -396,10 +409,24 @@ func (s *GitStore) addTaskMetadataToTree(baseTreeHash plumbing.Hash, opts WriteT
// ListTemporaryCheckpoints lists all checkpoint commits on a shadow branch.
// This returns individual commits (rewind points), not just branch info.
// The sessionID filter, if provided, limits results to commits from that session.
func (s *GitStore) ListTemporaryCheckpoints(ctx context.Context, baseCommit string, sessionID string, limit int) ([]TemporaryCheckpointInfo, error) {
// worktreeID should be empty for main worktree or the internal git worktree name for linked worktrees.
func (s *GitStore) ListTemporaryCheckpoints(ctx context.Context, baseCommit, worktreeID, sessionID string, limit int) ([]TemporaryCheckpointInfo, error) {
shadowBranchName := ShadowBranchNameForCommit(baseCommit, worktreeID)
return s.listCheckpointsForBranch(ctx, shadowBranchName, sessionID, limit)
}

// ListCheckpointsForBranch lists checkpoint commits for a shadow branch by name.
// Use this when you already have the full branch name (e.g., from ListTemporary).
// The sessionID filter, if provided, limits results to commits from that session.
func (s *GitStore) ListCheckpointsForBranch(ctx context.Context, branchName, sessionID string, limit int) ([]TemporaryCheckpointInfo, error) {
return s.listCheckpointsForBranch(ctx, branchName, sessionID, limit)
}

// listCheckpointsForBranch lists checkpoint commits for a specific shadow branch name.
// This is an internal helper used by ListTemporaryCheckpoints, ListCheckpointsForBranch, and ListAllTemporaryCheckpoints.
func (s *GitStore) listCheckpointsForBranch(ctx context.Context, shadowBranchName, sessionID string, limit int) ([]TemporaryCheckpointInfo, error) {
_ = ctx // Reserved for future use

shadowBranchName := ShadowBranchNameForCommit(baseCommit)
refName := plumbing.NewBranchReferenceName(shadowBranchName)

ref, err := s.repo.Reference(refName, true)
Expand Down Expand Up @@ -487,8 +514,8 @@ func (s *GitStore) ListAllTemporaryCheckpoints(ctx context.Context, sessionID st

// Iterate through each shadow branch and collect checkpoints
for _, branch := range branches {
// Use the base commit from the branch to get checkpoints
branchCheckpoints, branchErr := s.ListTemporaryCheckpoints(ctx, branch.BaseCommit, sessionID, limit)
// Use the branch name directly to get checkpoints
branchCheckpoints, branchErr := s.listCheckpointsForBranch(ctx, branch.BranchName, sessionID, limit)
if branchErr != nil {
continue // Skip branches we can't read
}
Expand Down Expand Up @@ -562,31 +589,57 @@ func (s *GitStore) GetTranscriptFromCommit(commitHash plumbing.Hash, metadataDir
return nil, ErrNoTranscript
}

// ShadowBranchExists checks if a shadow branch exists for the given base commit.
func (s *GitStore) ShadowBranchExists(baseCommit string) bool {
shadowBranchName := ShadowBranchNameForCommit(baseCommit)
// ShadowBranchExists checks if a shadow branch exists for the given base commit and worktree.
// worktreeID should be empty for main worktree or the internal git worktree name for linked worktrees.
func (s *GitStore) ShadowBranchExists(baseCommit, worktreeID string) bool {
shadowBranchName := ShadowBranchNameForCommit(baseCommit, worktreeID)
refName := plumbing.NewBranchReferenceName(shadowBranchName)
_, err := s.repo.Reference(refName, true)
return err == nil
}

// DeleteShadowBranch deletes the shadow branch for the given base commit.
func (s *GitStore) DeleteShadowBranch(baseCommit string) error {
shadowBranchName := ShadowBranchNameForCommit(baseCommit)
// DeleteShadowBranch deletes the shadow branch for the given base commit and worktree.
// worktreeID should be empty for main worktree or the internal git worktree name for linked worktrees.
func (s *GitStore) DeleteShadowBranch(baseCommit, worktreeID string) error {
shadowBranchName := ShadowBranchNameForCommit(baseCommit, worktreeID)
refName := plumbing.NewBranchReferenceName(shadowBranchName)
if err := s.repo.Storer.RemoveReference(refName); err != nil {
return fmt.Errorf("failed to delete shadow branch %s: %w", shadowBranchName, err)
}
return nil
}

// ShadowBranchNameForCommit returns the shadow branch name for a base commit hash.
// Uses the first ShadowBranchHashLength characters of the commit hash.
func ShadowBranchNameForCommit(baseCommit string) string {
// ShadowBranchNameForCommit returns the shadow branch name for a base commit hash
// and worktree identifier. The worktree ID should be empty for the main worktree
// or the internal git worktree name for linked worktrees.
// Format: entire/<commit[:7]>-<hash(worktreeID)[:6]>
func ShadowBranchNameForCommit(baseCommit, worktreeID string) string {
commitPart := baseCommit
if len(baseCommit) >= ShadowBranchHashLength {
return ShadowBranchPrefix + baseCommit[:ShadowBranchHashLength]
commitPart = baseCommit[:ShadowBranchHashLength]
}
worktreeHash := HashWorktreeID(worktreeID)
return ShadowBranchPrefix + commitPart + "-" + worktreeHash
}

// ParseShadowBranchName extracts the commit prefix and worktree hash from a shadow branch name.
// Input format: "entire/<commit[:7]>-<worktreeHash[:6]>"
// Returns (commitPrefix, worktreeHash, ok). Returns ("", "", false) if not a valid shadow branch.
func ParseShadowBranchName(branchName string) (commitPrefix, worktreeHash string, ok bool) {
if !strings.HasPrefix(branchName, ShadowBranchPrefix) {
return "", "", false
}
return ShadowBranchPrefix + baseCommit
suffix := strings.TrimPrefix(branchName, ShadowBranchPrefix)

// Find the last dash - everything before is commit prefix, after is worktree hash
lastDash := strings.LastIndex(suffix, "-")
if lastDash == -1 || lastDash == 0 || lastDash == len(suffix)-1 {
// No dash, or dash at start/end - invalid format
// Could be old format "entire/<commit[:7]>" without worktree hash
return suffix, "", true // Return as commit prefix with empty worktree hash
}

return suffix[:lastDash], suffix[lastDash+1:], true
}

// getOrCreateShadowBranch gets or creates the shadow branch for checkpoints.
Expand Down
Loading