diff --git a/CLAUDE.md b/CLAUDE.md index 096170d43..9664105d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -189,15 +206,16 @@ All strategies implement: | Strategy | Main Branch | Metadata Storage | Use Case | |----------|-------------|------------------|----------| -| **manual-commit** (default) | Unchanged (no commits) | `entire/` branches + `entire/sessions` | Recommended for most workflows | +| **manual-commit** (default) | Unchanged (no commits) | `entire/-` 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/` per base commit for checkpoints -- **Supports multiple concurrent sessions** - checkpoints from different sessions interleave on the same shadow branch +- Creates shadow branch `entire/-` 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`) @@ -248,7 +266,7 @@ All strategies implement: #### Metadata Structure -**Shadow Strategy** - Shadow branches (`entire/`): +**Shadow Strategy** - Shadow branches (`entire/-`): ``` .entire/metadata// ├── full.jsonl # Session transcript @@ -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/`) - manual-commit only:** +**On shadow branch commits (`entire/-`) - manual-commit only:** - `Entire-Session: ` - Session identifier - `Entire-Metadata: ` - Path to metadata directory within the tree - `Entire-Task-Metadata: ` - Path to task metadata directory (for task checkpoints) @@ -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/` to `entire/` +- Action: shadow branch is renamed from `entire/-` to `entire/-` - Session continues seamlessly with checkpoints preserved #### When Modifying Strategies @@ -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. diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index e40853a25..1c6b6158f 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -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) @@ -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 @@ -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 diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index 5301fef57..0fbc17cbf 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -2,6 +2,8 @@ package checkpoint import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -33,11 +35,21 @@ const ( // Shadow branches are named "entire/" 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/. // Returns the result containing commit hash and whether it was skipped. @@ -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) @@ -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) @@ -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/-" format) + baseCommit, _, _ := ParseShadowBranchName(branchName) results = append(results, TemporaryInfo{ BranchName: branchName, @@ -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) @@ -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) @@ -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 } @@ -562,17 +589,19 @@ 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) @@ -580,13 +609,37 @@ func (s *GitStore) DeleteShadowBranch(baseCommit string) error { 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/- +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/-" +// 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/" 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. diff --git a/cmd/entire/cli/checkpoint/temporary_test.go b/cmd/entire/cli/checkpoint/temporary_test.go new file mode 100644 index 000000000..fbfaef854 --- /dev/null +++ b/cmd/entire/cli/checkpoint/temporary_test.go @@ -0,0 +1,198 @@ +package checkpoint + +import "testing" + +func TestHashWorktreeID(t *testing.T) { + tests := []struct { + name string + worktreeID string + wantLen int + }{ + { + name: "empty string (main worktree)", + worktreeID: "", + wantLen: 6, + }, + { + name: "simple worktree name", + worktreeID: "test-123", + wantLen: 6, + }, + { + name: "complex worktree name", + worktreeID: "feature/auth-system", + wantLen: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HashWorktreeID(tt.worktreeID) + if len(got) != tt.wantLen { + t.Errorf("HashWorktreeID(%q) length = %d, want %d", tt.worktreeID, len(got), tt.wantLen) + } + }) + } +} + +func TestHashWorktreeID_Deterministic(t *testing.T) { + // Same input should always produce same output + id := "test-worktree" + hash1 := HashWorktreeID(id) + hash2 := HashWorktreeID(id) + if hash1 != hash2 { + t.Errorf("HashWorktreeID not deterministic: %q != %q", hash1, hash2) + } +} + +func TestHashWorktreeID_DifferentInputs(t *testing.T) { + // Different inputs should produce different outputs + hash1 := HashWorktreeID("worktree-a") + hash2 := HashWorktreeID("worktree-b") + if hash1 == hash2 { + t.Errorf("HashWorktreeID produced same hash for different inputs: %q", hash1) + } +} + +func TestShadowBranchNameForCommit(t *testing.T) { + tests := []struct { + name string + baseCommit string + worktreeID string + want string + }{ + { + name: "main worktree", + baseCommit: "abc1234567890", + worktreeID: "", + want: "entire/abc1234-" + HashWorktreeID(""), + }, + { + name: "linked worktree", + baseCommit: "abc1234567890", + worktreeID: "test-123", + want: "entire/abc1234-" + HashWorktreeID("test-123"), + }, + { + name: "short commit hash", + baseCommit: "abc", + worktreeID: "wt", + want: "entire/abc-" + HashWorktreeID("wt"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ShadowBranchNameForCommit(tt.baseCommit, tt.worktreeID) + if got != tt.want { + t.Errorf("ShadowBranchNameForCommit(%q, %q) = %q, want %q", + tt.baseCommit, tt.worktreeID, got, tt.want) + } + }) + } +} + +func TestParseShadowBranchName(t *testing.T) { + tests := []struct { + name string + branchName string + wantCommit string + wantWorktree string + wantOK bool + }{ + { + name: "new format with worktree hash", + branchName: "entire/abc1234-e3b0c4", + wantCommit: "abc1234", + wantWorktree: "e3b0c4", + wantOK: true, + }, + { + name: "old format without worktree hash", + branchName: "entire/abc1234", + wantCommit: "abc1234", + wantWorktree: "", + wantOK: true, + }, + { + name: "full commit hash with worktree", + branchName: "entire/abcdef1234567890-fedcba", + wantCommit: "abcdef1234567890", + wantWorktree: "fedcba", + wantOK: true, + }, + { + name: "not a shadow branch", + branchName: "main", + wantCommit: "", + wantWorktree: "", + wantOK: false, + }, + { + name: "entire/sessions is not a shadow branch", + branchName: "entire/sessions", + wantCommit: "sessions", + wantWorktree: "", + wantOK: true, // Parser doesn't validate content, just extracts + }, + { + name: "empty suffix after prefix", + branchName: "entire/", + wantCommit: "", + wantWorktree: "", + wantOK: true, // Empty commit, empty worktree + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commit, worktree, ok := ParseShadowBranchName(tt.branchName) + if ok != tt.wantOK { + t.Errorf("ParseShadowBranchName(%q) ok = %v, want %v", tt.branchName, ok, tt.wantOK) + } + if commit != tt.wantCommit { + t.Errorf("ParseShadowBranchName(%q) commit = %q, want %q", tt.branchName, commit, tt.wantCommit) + } + if worktree != tt.wantWorktree { + t.Errorf("ParseShadowBranchName(%q) worktree = %q, want %q", tt.branchName, worktree, tt.wantWorktree) + } + }) + } +} + +func TestParseShadowBranchName_RoundTrip(t *testing.T) { + // Test that ShadowBranchNameForCommit and ParseShadowBranchName are inverses + testCases := []struct { + baseCommit string + worktreeID string + }{ + {"abc1234567890", ""}, + {"abc1234567890", "test-worktree"}, + {"deadbeef", "feature/auth"}, + } + + for _, tc := range testCases { + branchName := ShadowBranchNameForCommit(tc.baseCommit, tc.worktreeID) + commitPrefix, worktreeHash, ok := ParseShadowBranchName(branchName) + + if !ok { + t.Errorf("ParseShadowBranchName failed for %q", branchName) + continue + } + + // Commit should be truncated to 7 chars + expectedCommit := tc.baseCommit + if len(expectedCommit) > ShadowBranchHashLength { + expectedCommit = expectedCommit[:ShadowBranchHashLength] + } + if commitPrefix != expectedCommit { + t.Errorf("Round trip commit mismatch: got %q, want %q", commitPrefix, expectedCommit) + } + + // Worktree hash should match + expectedWorktree := HashWorktreeID(tc.worktreeID) + if worktreeHash != expectedWorktree { + t.Errorf("Round trip worktree mismatch: got %q, want %q", worktreeHash, expectedWorktree) + } + } +} diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 1c8c86c31..bfbd0c4ab 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -907,7 +907,7 @@ func getReachableTemporaryCheckpoints(repo *git.Repository, store *checkpoint.Gi } // List checkpoints from this shadow branch - tempCheckpoints, _ := store.ListTemporaryCheckpoints(context.Background(), sb.BaseCommit, "", limit) //nolint:errcheck // Best-effort + tempCheckpoints, _ := store.ListCheckpointsForBranch(context.Background(), sb.BranchName, "", limit) //nolint:errcheck // Best-effort for _, tc := range tempCheckpoints { point := convertTemporaryCheckpoint(repo, tc) if point != nil { diff --git a/cmd/entire/cli/hooks_claudecode_handlers.go b/cmd/entire/cli/hooks_claudecode_handlers.go index 6fc63cd7d..518e248d6 100644 --- a/cmd/entire/cli/hooks_claudecode_handlers.go +++ b/cmd/entire/cli/hooks_claudecode_handlers.go @@ -158,11 +158,17 @@ func checkConcurrentSessions(ag agent.Agent, entireSessionID string) (bool, erro // Non-fatal: continue without worktree path worktreePath = "" } + worktreeID, err := paths.GetWorktreeID(worktreePath) + if err != nil { + // Non-fatal: continue with empty worktree ID (main worktree) + worktreeID = "" + } agentType := ag.Type() newState := &strategy.SessionState{ SessionID: entireSessionID, BaseCommit: head.Hash().String(), WorktreePath: worktreePath, + WorktreeID: worktreeID, ConcurrentWarningShown: true, StartedAt: time.Now(), AgentType: agentType, @@ -187,7 +193,7 @@ func checkConcurrentSessions(ag agent.Agent, entireSessionID string) (bool, erro } // Try to read the other session's initial prompt - otherPrompt := strategy.ReadSessionPromptFromShadow(repo, otherSession.BaseCommit, otherSession.SessionID) + otherPrompt := strategy.ReadSessionPromptFromShadow(repo, otherSession.BaseCommit, otherSession.WorktreeID, otherSession.SessionID) // Build message with other session's prompt if available var message string @@ -253,35 +259,6 @@ func handleSessionStartCommon() error { // handleSessionInitErrors handles session initialization errors and provides user-friendly messages. func handleSessionInitErrors(ag agent.Agent, initErr error) error { - // Check for shadow branch conflict error (worktree conflict) - var conflictErr *strategy.ShadowBranchConflictError - if errors.As(initErr, &conflictErr) { - message := fmt.Sprintf( - "Warning: Shadow branch conflict detected!\n\n"+ - "Branch: %s\n"+ - "Existing session: %s\n"+ - "From worktree: %s\n"+ - "Started: %s\n\n"+ - "This may indicate another agent session is active from a different worktree,\n"+ - "or a previous session wasn't completed.\n\n"+ - "Options:\n"+ - "1. Commit your changes (git commit) to create a new base commit\n"+ - "2. Run 'entire rewind reset' to discard the shadow branch and start fresh\n"+ - "3. Continue the previous session from the original worktree: %s", - conflictErr.Branch, - conflictErr.ExistingSession, - conflictErr.ExistingWorktree, - conflictErr.LastActivity.Format(time.RFC822), - conflictErr.ExistingWorktree, - ) - // Output blocking JSON response - user must resolve conflict before continuing - if err := outputHookResponse(false, message); err != nil { - return err - } - // Return nil so hook exits cleanly (status 0), not with error status - return nil - } - // Check for session ID conflict error (shadow branch has different session) var sessionConflictErr *strategy.SessionIDConflictError if errors.As(initErr, &sessionConflictErr) { diff --git a/cmd/entire/cli/hooks_geminicli_handlers.go b/cmd/entire/cli/hooks_geminicli_handlers.go index 4f560dcdd..807d9e29e 100644 --- a/cmd/entire/cli/hooks_geminicli_handlers.go +++ b/cmd/entire/cli/hooks_geminicli_handlers.go @@ -145,7 +145,7 @@ func checkConcurrentSessionsGemini(entireSessionID string) { } // Try to read the other session's initial prompt - otherPrompt := strategy.ReadSessionPromptFromShadow(repo, otherSession.BaseCommit, otherSession.SessionID) + otherPrompt := strategy.ReadSessionPromptFromShadow(repo, otherSession.BaseCommit, otherSession.WorktreeID, otherSession.SessionID) // Build message - matches Claude Code format but with Gemini-specific instructions var message string diff --git a/cmd/entire/cli/integration_test/last_checkpoint_id_test.go b/cmd/entire/cli/integration_test/last_checkpoint_id_test.go index dddacbcd0..a68ba682c 100644 --- a/cmd/entire/cli/integration_test/last_checkpoint_id_test.go +++ b/cmd/entire/cli/integration_test/last_checkpoint_id_test.go @@ -330,12 +330,8 @@ func TestShadowStrategy_ShadowBranchCleanedUpAfterCondensation(t *testing.T) { if err != nil { t.Fatalf("Failed to get session state: %v", err) } - // Shadow branch uses 7-char prefix of base commit - baseCommitPrefix := state.BaseCommit - if len(baseCommitPrefix) > 7 { - baseCommitPrefix = baseCommitPrefix[:7] - } - shadowBranchName := "entire/" + baseCommitPrefix + // Shadow branch uses worktree-specific naming + shadowBranchName := env.GetShadowBranchNameForCommit(state.BaseCommit) env.WriteFile("test.txt", "test content") session.CreateTranscript("Create test file", []FileChange{ diff --git a/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go b/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go index 0eb9e8416..aac4af5f5 100644 --- a/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go +++ b/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go @@ -82,7 +82,7 @@ func TestShadow_UntrackedFilePreservation(t *testing.T) { } // Verify shadow branch created - expectedShadowBranch := "entire/" + initialHead[:7] + expectedShadowBranch := env.GetShadowBranchNameForCommit(initialHead) if !env.BranchExists(expectedShadowBranch) { t.Errorf("Expected shadow branch %s to exist", expectedShadowBranch) } diff --git a/cmd/entire/cli/integration_test/manual_commit_workflow_test.go b/cmd/entire/cli/integration_test/manual_commit_workflow_test.go index 2bd87baf5..51bc94765 100644 --- a/cmd/entire/cli/integration_test/manual_commit_workflow_test.go +++ b/cmd/entire/cli/integration_test/manual_commit_workflow_test.go @@ -82,8 +82,8 @@ func TestShadow_FullWorkflow(t *testing.T) { t.Fatalf("SimulateStop (checkpoint 1) failed: %v", err) } - // Verify shadow branch created with correct naming (entire/) - expectedShadowBranch := "entire/" + initialHead[:7] + // Verify shadow branch created with correct worktree-specific naming + expectedShadowBranch := env.GetShadowBranchNameForCommit(initialHead) if !env.BranchExists(expectedShadowBranch) { t.Errorf("Expected shadow branch %s to exist", expectedShadowBranch) } @@ -289,7 +289,7 @@ func TestShadow_FullWorkflow(t *testing.T) { } // Verify NEW shadow branch created (based on new HEAD) - expectedShadowBranch2 := "entire/" + commit1Hash[:7] + expectedShadowBranch2 := env.GetShadowBranchNameForCommit(commit1Hash) if !env.BranchExists(expectedShadowBranch2) { t.Errorf("Expected new shadow branch %s after commit", expectedShadowBranch2) } @@ -492,7 +492,7 @@ func TestShadow_ShadowBranchMigrationOnPull(t *testing.T) { env.InitEntire(strategy.StrategyNameManualCommit) originalHead := env.GetHeadHash() - originalShadowBranch := "entire/" + originalHead[:7] + originalShadowBranch := env.GetShadowBranchNameForCommit(originalHead) // Start session and create checkpoint session := env.NewSession() @@ -520,7 +520,7 @@ func TestShadow_ShadowBranchMigrationOnPull(t *testing.T) { env.GitCommit("Simulated pull commit") newHead := env.GetHeadHash() - newShadowBranch := "entire/" + newHead[:7] + newShadowBranch := env.GetShadowBranchNameForCommit(newHead) t.Logf("After simulated pull: old=%s new=%s", originalHead[:7], newHead[:7]) // Restore the file (simulating stash apply) @@ -598,8 +598,8 @@ func TestShadow_ShadowBranchNaming(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } - // Verify shadow branch name matches entire/ - expectedBranch := "entire/" + baseHead[:7] + // Verify shadow branch name matches worktree-specific format + expectedBranch := env.GetShadowBranchNameForCommit(baseHead) if !env.BranchExists(expectedBranch) { t.Errorf("Shadow branch should be named %s", expectedBranch) } @@ -1454,7 +1454,7 @@ func TestShadow_RewindPreservesUntrackedFilesWithExistingShadowBranch(t *testing } // Verify shadow branch exists - shadowBranchName := "entire/" + env.GetHeadHash()[:7] + shadowBranchName := env.GetShadowBranchName() if !env.BranchExists(shadowBranchName) { t.Fatalf("Shadow branch %s should exist after first session", shadowBranchName) } diff --git a/cmd/entire/cli/integration_test/session_conflict_test.go b/cmd/entire/cli/integration_test/session_conflict_test.go index 7ff3a8248..c67093740 100644 --- a/cmd/entire/cli/integration_test/session_conflict_test.go +++ b/cmd/entire/cli/integration_test/session_conflict_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "entire.io/cli/cmd/entire/cli/paths" "entire.io/cli/cmd/entire/cli/sessionid" "entire.io/cli/cmd/entire/cli/strategy" @@ -34,7 +35,7 @@ func TestSessionIDConflict_OrphanedBranchIsReset(t *testing.T) { env.InitEntire(strategy.StrategyNameManualCommit) baseHead := env.GetHeadHash() - shadowBranch := "entire/" + baseHead[:7] + shadowBranch := env.GetShadowBranchNameForCommit(baseHead) // Create a session and checkpoint (this creates the shadow branch) session1 := env.NewSession() @@ -144,7 +145,7 @@ func TestSessionIDConflict_NoShadowBranch(t *testing.T) { env.InitEntire(strategy.StrategyNameManualCommit) baseHead := env.GetHeadHash() - shadowBranch := "entire/" + baseHead[:7] + shadowBranch := env.GetShadowBranchNameForCommit(baseHead) // Verify no shadow branch exists if env.BranchExists(shadowBranch) { @@ -175,7 +176,7 @@ func TestSessionIDConflict_ManuallyCreatedOrphanedBranch(t *testing.T) { env.InitEntire(strategy.StrategyNameManualCommit) baseHead := env.GetHeadHash() - shadowBranch := "entire/" + baseHead[:7] + shadowBranch := env.GetShadowBranchNameForCommit(baseHead) // Manually create a shadow branch with a different session ID // This simulates a shadow branch that was left behind (e.g., from a crash) @@ -211,7 +212,8 @@ func TestSessionIDConflict_ManuallyCreatedOrphanedBranch(t *testing.T) { // TestSessionIDConflict_ExistingSessionWithState tests that when a shadow branch exists // from a different session AND that session has a state file (not orphaned), a blocking -// hook response is returned. This simulates the cross-worktree scenario. +// hook response is returned. This simulates the same-worktree, different-session scenario +// (e.g., concurrent sessions in the same directory). func TestSessionIDConflict_ExistingSessionWithState(t *testing.T) { env := NewTestEnv(t) defer env.Cleanup() @@ -226,7 +228,11 @@ func TestSessionIDConflict_ExistingSessionWithState(t *testing.T) { env.InitEntire(strategy.StrategyNameManualCommit) baseHead := env.GetHeadHash() - shadowBranch := "entire/" + baseHead[:7] + worktreeID, err := paths.GetWorktreeID(env.RepoDir) + if err != nil { + t.Fatalf("Failed to get worktree ID: %v", err) + } + shadowBranch := env.GetShadowBranchNameForCommit(baseHead) // Create a shadow branch with a specific session ID otherSessionID := "other-session-id" @@ -237,13 +243,14 @@ func TestSessionIDConflict_ExistingSessionWithState(t *testing.T) { t.Fatalf("Shadow branch %s should exist after creation", shadowBranch) } - // Manually create a state file for the other session (simulating cross-worktree scenario) + // Manually create a state file for the other session (same worktree, different session) // This makes the shadow branch NOT orphaned entireOtherSessionID := sessionid.EntireSessionID(otherSessionID) otherState := &strategy.SessionState{ SessionID: entireOtherSessionID, BaseCommit: baseHead, - WorktreePath: "/some/other/worktree", // Different worktree + WorktreePath: env.RepoDir, // Same worktree + WorktreeID: worktreeID, // Same worktree ID CheckpointCount: 1, } // Write state file directly to test repo (can't use strategy.SaveSessionState as it uses cwd) @@ -266,6 +273,7 @@ func TestSessionIDConflict_ExistingSessionWithState(t *testing.T) { } // Try to start a new session - should return blocking response (not error) + // The concurrent session check runs FIRST and shows a warning about the other session session := env.NewSession() hookResp, err := env.SimulateUserPromptSubmitWithResponse(session.ID) // After the fix, the hook should succeed (no error) but return blocking response @@ -274,17 +282,18 @@ func TestSessionIDConflict_ExistingSessionWithState(t *testing.T) { } // Verify the hook response blocks and contains expected message + // The concurrent session warning shows "Another session is active" message if hookResp == nil { t.Fatal("Expected hook response, got nil") } if hookResp.Continue { t.Error("Expected hook to block (Continue: false)") } - if !strings.Contains(hookResp.StopReason, "Session ID conflict") { - t.Errorf("Expected 'Session ID conflict' in stop reason, got: %s", hookResp.StopReason) + if !strings.Contains(hookResp.StopReason, "Another session is active") { + t.Errorf("Expected 'Another session is active' in stop reason, got: %s", hookResp.StopReason) } - if !strings.Contains(hookResp.StopReason, shadowBranch) { - t.Errorf("Expected shadow branch %s in message, got: %s", shadowBranch, hookResp.StopReason) + if !strings.Contains(hookResp.StopReason, "other-session-id") { + t.Errorf("Expected other session ID in message, got: %s", hookResp.StopReason) } t.Logf("Got expected blocking response: %s", hookResp.StopReason) } @@ -368,7 +377,7 @@ func TestSessionIDConflict_ShadowBranchWithoutTrailer(t *testing.T) { env.InitEntire(strategy.StrategyNameManualCommit) baseHead := env.GetHeadHash() - shadowBranch := "entire/" + baseHead[:7] + shadowBranch := env.GetShadowBranchNameForCommit(baseHead) // Create a shadow branch without Entire-Session trailer (simulating old format) createShadowBranchWithoutTrailer(t, env.RepoDir, shadowBranch) diff --git a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go index f1c7ca6e9..c652ccb99 100644 --- a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go +++ b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go @@ -361,15 +361,8 @@ func verifyShadowCheckpointStorage(t *testing.T, env *TestEnv, sessionID, taskTo t.Fatalf("failed to open repo: %v", err) } - // Get HEAD hash to determine shadow branch name - head, err := repo.Head() - if err != nil { - t.Fatalf("failed to get HEAD: %v", err) - } - headHash := head.Hash().String() - - // Shadow branch is entire/ - shadowBranchName := "entire/" + headHash[:7] + // Get shadow branch name using worktree-specific naming + shadowBranchName := env.GetShadowBranchName() // Get shadow branch reference shadowRef, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranchName), true) diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index b8dfbc601..efad0f2ee 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -14,6 +14,7 @@ import ( "time" "entire.io/cli/cmd/entire/cli/agent" + "entire.io/cli/cmd/entire/cli/checkpoint" "entire.io/cli/cmd/entire/cli/checkpoint/id" "entire.io/cli/cmd/entire/cli/jsonutil" "entire.io/cli/cmd/entire/cli/paths" @@ -536,6 +537,31 @@ func (env *TestEnv) GetHeadHash() string { return head.Hash().String() } +// GetShadowBranchName returns the worktree-specific shadow branch name for the current HEAD. +// Format: entire/- +func (env *TestEnv) GetShadowBranchName() string { + env.T.Helper() + + headHash := env.GetHeadHash() + worktreeID, err := paths.GetWorktreeID(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to get worktree ID: %v", err) + } + return checkpoint.ShadowBranchNameForCommit(headHash, worktreeID) +} + +// GetShadowBranchNameForCommit returns the worktree-specific shadow branch name for a given commit. +// Format: entire/- +func (env *TestEnv) GetShadowBranchNameForCommit(commitHash string) string { + env.T.Helper() + + worktreeID, err := paths.GetWorktreeID(env.RepoDir) + if err != nil { + env.T.Fatalf("failed to get worktree ID: %v", err) + } + return checkpoint.ShadowBranchNameForCommit(commitHash, worktreeID) +} + // GetGitLog returns a list of commit hashes from HEAD. func (env *TestEnv) GetGitLog() []string { env.T.Helper() diff --git a/cmd/entire/cli/paths/worktree.go b/cmd/entire/cli/paths/worktree.go new file mode 100644 index 000000000..ab20de7a5 --- /dev/null +++ b/cmd/entire/cli/paths/worktree.go @@ -0,0 +1,51 @@ +package paths + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// GetWorktreeID returns the internal git worktree identifier for the given path. +// For the main worktree (where .git is a directory), returns empty string. +// For linked worktrees (where .git is a file), extracts the name from +// .git/worktrees// path. This name is stable across `git worktree move`. +func GetWorktreeID(worktreePath string) (string, error) { + gitPath := filepath.Join(worktreePath, ".git") + + info, err := os.Stat(gitPath) + if err != nil { + return "", fmt.Errorf("failed to stat .git: %w", err) + } + + // Main worktree has .git as a directory + if info.IsDir() { + return "", nil + } + + // Linked worktree has .git as a file with content: "gitdir: /path/to/.git/worktrees/" + content, err := os.ReadFile(gitPath) //nolint:gosec // gitPath is constructed from worktreePath + ".git" + if err != nil { + return "", fmt.Errorf("failed to read .git file: %w", err) + } + + line := strings.TrimSpace(string(content)) + if !strings.HasPrefix(line, "gitdir: ") { + return "", fmt.Errorf("invalid .git file format: %s", line) + } + + gitdir := strings.TrimPrefix(line, "gitdir: ") + + // Extract worktree name from path like /repo/.git/worktrees/ + // The path after ".git/worktrees/" is the worktree identifier + const marker = ".git/worktrees/" + _, worktreeID, found := strings.Cut(gitdir, marker) + if !found { + return "", fmt.Errorf("unexpected gitdir format (no worktrees): %s", gitdir) + } + // Remove trailing slashes if any + worktreeID = strings.TrimSuffix(worktreeID, "/") + + return worktreeID, nil +} diff --git a/cmd/entire/cli/paths/worktree_test.go b/cmd/entire/cli/paths/worktree_test.go new file mode 100644 index 000000000..377e5a0fc --- /dev/null +++ b/cmd/entire/cli/paths/worktree_test.go @@ -0,0 +1,95 @@ +package paths + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGetWorktreeID(t *testing.T) { + tests := []struct { + name string + setupFunc func(dir string) error + wantID string + wantErr bool + errContain string + }{ + { + name: "main worktree (git directory)", + setupFunc: func(dir string) error { + return os.MkdirAll(filepath.Join(dir, ".git"), 0o755) + }, + wantID: "", + }, + { + name: "linked worktree simple name", + setupFunc: func(dir string) error { + content := "gitdir: /some/repo/.git/worktrees/test-wt\n" + return os.WriteFile(filepath.Join(dir, ".git"), []byte(content), 0o644) + }, + wantID: "test-wt", + }, + { + name: "linked worktree with subdirectory name", + setupFunc: func(dir string) error { + content := "gitdir: /repo/.git/worktrees/feature/auth-system\n" + return os.WriteFile(filepath.Join(dir, ".git"), []byte(content), 0o644) + }, + wantID: "feature/auth-system", + }, + { + name: "no .git exists", + setupFunc: func(_ string) error { + return nil // Don't create .git + }, + wantErr: true, + errContain: "failed to stat .git", + }, + { + name: "invalid .git file format", + setupFunc: func(dir string) error { + return os.WriteFile(filepath.Join(dir, ".git"), []byte("invalid content"), 0o644) + }, + wantErr: true, + errContain: "invalid .git file format", + }, + { + name: "gitdir without worktrees path", + setupFunc: func(dir string) error { + content := "gitdir: /some/repo/.git\n" + return os.WriteFile(filepath.Join(dir, ".git"), []byte(content), 0o644) + }, + wantErr: true, + errContain: "unexpected gitdir format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if err := tt.setupFunc(dir); err != nil { + t.Fatalf("setup failed: %v", err) + } + + id, err := GetWorktreeID(dir) + + if tt.wantErr { + if err == nil { + t.Fatalf("GetWorktreeID() error = nil, want error containing %q", tt.errContain) + } + if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("GetWorktreeID() error = %v, want error containing %q", err, tt.errContain) + } + return + } + + if err != nil { + t.Fatalf("GetWorktreeID() error = %v, want nil", err) + } + if id != tt.wantID { + t.Errorf("GetWorktreeID() = %q, want %q", id, tt.wantID) + } + }) + } +} diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index abb16e617..809adc7ae 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -33,6 +33,10 @@ type State struct { // WorktreePath is the absolute path to the worktree root WorktreePath string `json:"worktree_path,omitempty"` + // WorktreeID is the internal git worktree identifier (empty for main worktree) + // Derived from .git/worktrees//, stable across git worktree move + WorktreeID string `json:"worktree_id,omitempty"` + // StartedAt is when the session was started StartedAt time.Time `json:"started_at"` diff --git a/cmd/entire/cli/strategy/clean_test.go b/cmd/entire/cli/strategy/clean_test.go index 4d3ae126b..62b1bb994 100644 --- a/cmd/entire/cli/strategy/clean_test.go +++ b/cmd/entire/cli/strategy/clean_test.go @@ -4,6 +4,8 @@ import ( "testing" "time" + "entire.io/cli/cmd/entire/cli/checkpoint" + "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) @@ -14,17 +16,23 @@ func TestIsShadowBranch(t *testing.T) { branchName string want bool }{ - // Valid shadow branches (7+ hex chars) - {"7 hex chars", "entire/abc1234", true}, - {"7 hex chars numeric", "entire/1234567", true}, - {"full commit hash", "entire/abcdef0123456789abcdef0123456789abcdef01", true}, - {"mixed case hex", "entire/AbCdEf1", true}, + // Valid shadow branches - old format (7+ hex chars) + {"old format: 7 hex chars", "entire/abc1234", true}, + {"old format: 7 hex chars numeric", "entire/1234567", true}, + {"old format: full commit hash", "entire/abcdef0123456789abcdef0123456789abcdef01", true}, + {"old format: mixed case hex", "entire/AbCdEf1", true}, + + // Valid shadow branches - new format with worktree hash (7 hex + dash + 6 hex) + {"new format: standard", "entire/abc1234-e3b0c4", true}, + {"new format: numeric worktree hash", "entire/1234567-123456", true}, + {"new format: full commit with worktree", "entire/abcdef0123456789-fedcba", true}, + {"new format: mixed case", "entire/AbCdEf1-AbCdEf", true}, // Invalid patterns {"empty after prefix", "entire/", false}, - {"too short (6 chars)", "entire/abc123", false}, - {"too short (1 char)", "entire/a", false}, - {"non-hex chars", "entire/ghijklm", false}, + {"too short commit (6 chars)", "entire/abc123", false}, + {"too short commit (1 char)", "entire/a", false}, + {"non-hex chars in commit", "entire/ghijklm", false}, {"sessions branch", "entire/sessions", false}, {"no prefix", "abc1234", false}, {"wrong prefix", "feature/abc1234", false}, @@ -33,6 +41,10 @@ func TestIsShadowBranch(t *testing.T) { {"empty string", "", false}, {"just entire", "entire", false}, {"entire with slash only", "entire/", false}, + {"worktree hash too short (5 chars)", "entire/abc1234-e3b0c", false}, + {"worktree hash too long (7 chars)", "entire/abc1234-e3b0c44", false}, + {"non-hex in worktree hash", "entire/abc1234-ghijkl", false}, + {"missing commit hash", "entire/-e3b0c4", false}, } for _, tt := range tests { @@ -353,19 +365,13 @@ func TestListOrphanedSessionStates_RecentSessionNotOrphaned(t *testing.T) { } } -// TestListOrphanedSessionStates_HashLengthMismatch tests that session states are correctly -// matched against shadow branches even when hash lengths differ. -// -// P1 Bug: Shadow branches use 7-char hashes (e.g., "entire/abc1234") but session states -// store the full 40-char BaseCommit hash. The current comparison at line 192 does: -// -// shadowBranchSet[state.BaseCommit] -// -// where shadowBranchSet has 7-char keys but state.BaseCommit is 40 chars. -// This comparison always fails, causing valid sessions to be marked as orphaned. +// TestListOrphanedSessionStates_ShadowBranchMatching tests that session states are correctly +// matched against shadow branches using worktree-specific naming. // -// This test should FAIL with the current implementation, demonstrating the bug. -func TestListOrphanedSessionStates_HashLengthMismatch(t *testing.T) { +// Shadow branches use the format "entire/-" and session states +// store both the full BaseCommit and WorktreeID. The comparison constructs the expected +// branch name from these fields and checks if it exists. +func TestListOrphanedSessionStates_ShadowBranchMatching(t *testing.T) { // Setup: create a temp git repo dir := t.TempDir() repo, err := git.PlainInit(dir, false) @@ -392,21 +398,22 @@ func TestListOrphanedSessionStates_HashLengthMismatch(t *testing.T) { t.Fatalf("failed to set master: %v", err) } - // Create a shadow branch using the 7-char hash (matching real behavior) - // Real code: shadowBranch := "entire/" + baseHead[:7] - shortHash := commitHash.String()[:7] - shadowBranchName := "entire/" + shortHash + // Create a shadow branch using worktree-specific naming (matching real behavior) + // Real code: shadowBranch := checkpoint.ShadowBranchNameForCommit(baseCommit, worktreeID) + fullHash := commitHash.String() + worktreeID := "" // Main worktree + shadowBranchName := checkpoint.ShadowBranchNameForCommit(fullHash, worktreeID) shadowRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(shadowBranchName), commitHash) if err := repo.Storer.SetReference(shadowRef); err != nil { t.Fatalf("failed to create shadow branch: %v", err) } - // Create a session state with the FULL 40-char hash (matching real behavior) - // Real code: state.BaseCommit = head.Hash().String() - fullHash := commitHash.String() + // Create a session state with the FULL 40-char hash and WorktreeID (matching real behavior) + // Real code: state.BaseCommit = head.Hash().String(), state.WorktreeID = worktreeID state := &SessionState{ SessionID: "session-with-shadow-branch", - BaseCommit: fullHash, // Full 40-char hash! + BaseCommit: fullHash, // Full 40-char hash + WorktreeID: worktreeID, StartedAt: time.Now().Add(-1 * time.Hour), CheckpointCount: 1, } @@ -414,7 +421,7 @@ func TestListOrphanedSessionStates_HashLengthMismatch(t *testing.T) { t.Fatalf("SaveSessionState() error = %v", err) } - // Verify the shadow branch exists and uses short hash + // Verify the shadow branch exists with worktree-specific name shadowBranches, err := ListShadowBranches() if err != nil { t.Fatalf("ListShadowBranches() error = %v", err) @@ -423,10 +430,10 @@ func TestListOrphanedSessionStates_HashLengthMismatch(t *testing.T) { t.Fatalf("Expected shadow branch %q, got %v", shadowBranchName, shadowBranches) } - // Verify the hash length mismatch exists - t.Logf("Shadow branch hash (7 chars): %q", shortHash) + // Log info about the branch naming + t.Logf("Shadow branch name: %q", shadowBranchName) t.Logf("Session BaseCommit (40 chars): %q", fullHash) - t.Logf("Are they equal? %v (they should match by prefix)", shortHash == fullHash) + t.Logf("Session WorktreeID: %q", worktreeID) // List orphaned session states orphaned, err := ListOrphanedSessionStates() @@ -435,19 +442,18 @@ func TestListOrphanedSessionStates_HashLengthMismatch(t *testing.T) { } // The session should NOT be marked as orphaned because it HAS a shadow branch! - // The shadow branch exists (entire/<7-char-hash>), but the current code compares - // the 7-char hash against the 40-char BaseCommit, which always fails. + // With worktree-specific naming, the expected branch name is constructed from + // BaseCommit and WorktreeID, which should match the actual shadow branch. for _, item := range orphaned { if item.ID == "session-with-shadow-branch" { - t.Errorf("ListOrphanedSessionStates() incorrectly marked session as orphaned due to hash length mismatch.\n"+ - "Shadow branch exists: %q (uses 7-char hash: %q)\n"+ - "Session BaseCommit: %q (40-char hash)\n"+ - "The comparison shadowBranchSet[state.BaseCommit] fails because:\n"+ - " - shadowBranchSet contains key %q (7 chars)\n"+ - " - state.BaseCommit is %q (40 chars)\n"+ - "Expected: session to be recognized as having a shadow branch.\n"+ + t.Errorf("ListOrphanedSessionStates() incorrectly marked session as orphaned.\n"+ + "Shadow branch exists: %q\n"+ + "Session BaseCommit: %q\n"+ + "Session WorktreeID: %q\n"+ + "Expected branch: %q\n"+ "Got: session marked as orphaned with reason: %q", - shadowBranchName, shortHash, fullHash, shortHash, fullHash, item.Reason) + shadowBranchName, fullHash, worktreeID, + checkpoint.ShadowBranchNameForCommit(fullHash, worktreeID), item.Reason) } } } diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index bd2b75490..ce81ab0b6 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -23,10 +23,6 @@ const ( // considered orphaned. This protects active sessions that haven't created // their first checkpoint yet. sessionGracePeriod = 10 * time.Minute - - // shadowBranchHashLength is the number of hex characters used in shadow branch names. - // Shadow branches are named "entire/" using a 7-char prefix of the commit hash. - shadowBranchHashLength = 7 ) // CleanupType identifies the type of orphaned item. @@ -55,13 +51,18 @@ type CleanupResult struct { FailedCheckpoints []string // Checkpoints that failed to delete } -// shadowBranchPattern matches shadow branch names: entire/<7+ hex chars> -// The pattern requires at least 7 hex characters after "entire/" -var shadowBranchPattern = regexp.MustCompile(`^entire/[0-9a-fA-F]{7,}$`) +// shadowBranchPattern matches shadow branch names in both old and new formats: +// - Old format: entire/ +// - New format: entire/- +// +// The pattern requires at least 7 hex characters for the commit, optionally followed +// by a dash and exactly 6 hex characters for the worktree hash. +var shadowBranchPattern = regexp.MustCompile(`^entire/[0-9a-fA-F]{7,}(-[0-9a-fA-F]{6})?$`) // IsShadowBranch returns true if the branch name matches the shadow branch pattern. -// Shadow branches have the format "entire/" where the commit hash -// is at least 7 hex characters. The "entire/sessions" branch is NOT a shadow branch. +// Shadow branches have the format "entire/-" where the +// commit hash is at least 7 hex characters and worktree hash is 6 hex characters. +// The "entire/sessions" branch is NOT a shadow branch. func IsShadowBranch(branchName string) bool { // Explicitly exclude entire/sessions if branchName == "entire/sessions" { @@ -187,15 +188,11 @@ func ListOrphanedSessionStates() ([]CleanupItem, error) { } } - // Get all shadow branches + // Get all shadow branches as a set for quick lookup shadowBranches, _ := ListShadowBranches() //nolint:errcheck // Best effort shadowBranchSet := make(map[string]bool) for _, branch := range shadowBranches { - // Extract commit hash from branch name (entire/) - if strings.HasPrefix(branch, "entire/") { - hash := strings.TrimPrefix(branch, "entire/") - shadowBranchSet[hash] = true - } + shadowBranchSet[branch] = true } var orphaned []CleanupItem @@ -211,12 +208,10 @@ func ListOrphanedSessionStates() ([]CleanupItem, error) { // Check if session has checkpoints on entire/sessions hasCheckpoints := sessionsWithCheckpoints[state.SessionID] - // Check if shadow branch exists for base commit - // Shadow branches use 7-char hash prefixes, so we need to match by prefix - hasShadowBranch := false - if len(state.BaseCommit) >= shadowBranchHashLength { - hasShadowBranch = shadowBranchSet[state.BaseCommit[:shadowBranchHashLength]] - } + // Check if shadow branch exists for this session's base commit and worktree + // Shadow branches are now worktree-specific: entire/- + expectedBranch := checkpoint.ShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) + hasShadowBranch := shadowBranchSet[expectedBranch] // Session is orphaned if it has no checkpoints AND no shadow branch if !hasCheckpoints && !hasShadowBranch { diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 3642f705c..85ca63ce9 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -13,6 +13,7 @@ import ( "time" "entire.io/cli/cmd/entire/cli/agent" + "entire.io/cli/cmd/entire/cli/checkpoint" "entire.io/cli/cmd/entire/cli/checkpoint/id" "entire.io/cli/cmd/entire/cli/paths" "entire.io/cli/cmd/entire/cli/trailers" @@ -350,21 +351,12 @@ func ReadAllSessionPromptsFromTree(tree *object.Tree, checkpointPath string, ses // ReadSessionPromptFromShadow reads the first prompt for a session from the shadow branch. // Returns an empty string if the prompt cannot be read. -func ReadSessionPromptFromShadow(repo *git.Repository, baseCommit, sessionID string) string { - // Get shadow branch for this base commit (try full hash first, then shortened) - shadowBranchName := shadowBranchPrefix + baseCommit +func ReadSessionPromptFromShadow(repo *git.Repository, baseCommit, worktreeID, sessionID string) string { + // Get shadow branch for this base commit using worktree-specific naming + shadowBranchName := checkpoint.ShadowBranchNameForCommit(baseCommit, worktreeID) ref, err := repo.Reference(plumbing.NewBranchReferenceName(shadowBranchName), true) if err != nil { - // Try shortened hash (7 chars) - if len(baseCommit) > 7 { - shadowBranchName = shadowBranchPrefix + baseCommit[:7] - ref, err = repo.Reference(plumbing.NewBranchReferenceName(shadowBranchName), true) - if err != nil { - return "" - } - } else { - return "" - } + return "" } commit, err := repo.CommitObject(ref.Hash()) diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 25fa4d28d..0d74f6d5d 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -64,6 +64,7 @@ func sessionStateToStrategy(state *session.State) *SessionState { SessionID: state.SessionID, BaseCommit: state.BaseCommit, WorktreePath: state.WorktreePath, + WorktreeID: state.WorktreeID, StartedAt: state.StartedAt, CheckpointCount: state.CheckpointCount, CondensedTranscriptLines: state.CondensedTranscriptLines, @@ -111,6 +112,7 @@ func sessionStateFromStrategy(state *SessionState) *session.State { SessionID: state.SessionID, BaseCommit: state.BaseCommit, WorktreePath: state.WorktreePath, + WorktreeID: state.WorktreeID, StartedAt: state.StartedAt, CheckpointCount: state.CheckpointCount, CondensedTranscriptLines: state.CondensedTranscriptLines, diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 32848fa4e..609be886a 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -102,7 +102,7 @@ func (s *ManualCommitStrategy) getCheckpointLog(checkpointID id.CheckpointID) ([ // Uses checkpoint.GitStore.WriteCommitted for the git operations. func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointID id.CheckpointID, state *SessionState) (*CondenseResult, error) { // Get shadow branch - shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit) + shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) ref, err := repo.Reference(refName, true) if err != nil { diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index 2593e65f3..8fca2c0a2 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -54,8 +54,8 @@ func (s *ManualCommitStrategy) SaveChanges(ctx SaveContext) error { } // Check if shadow branch exists to report whether we created it - shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit) - branchExisted := store.ShadowBranchExists(state.BaseCommit) + shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) + branchExisted := store.ShadowBranchExists(state.BaseCommit, state.WorktreeID) // Use the pending attribution calculated at prompt start (in InitializeSession) // This was calculated BEFORE the agent made changes, so it accurately captures user edits @@ -83,6 +83,7 @@ func (s *ManualCommitStrategy) SaveChanges(ctx SaveContext) error { result, err := store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{ SessionID: sessionID, BaseCommit: state.BaseCommit, + WorktreeID: state.WorktreeID, ModifiedFiles: ctx.ModifiedFiles, NewFiles: ctx.NewFiles, DeletedFiles: ctx.DeletedFiles, @@ -187,8 +188,8 @@ func (s *ManualCommitStrategy) SaveTaskCheckpoint(ctx TaskCheckpointContext) err } // Check if shadow branch exists to report whether we created it - shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit) - branchExisted := store.ShadowBranchExists(state.BaseCommit) + shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) + branchExisted := store.ShadowBranchExists(state.BaseCommit, state.WorktreeID) // Compute metadata paths for commit message sessionMetadataDir := paths.SessionMetadataDirFromEntireID(ctx.SessionID) @@ -223,6 +224,7 @@ func (s *ManualCommitStrategy) SaveTaskCheckpoint(ctx TaskCheckpointContext) err _, err = store.WriteTemporaryTask(context.Background(), checkpoint.WriteTemporaryTaskOptions{ SessionID: ctx.SessionID, BaseCommit: state.BaseCommit, + WorktreeID: state.WorktreeID, ToolUseID: ctx.ToolUseID, AgentID: ctx.AgentID, ModifiedFiles: ctx.ModifiedFiles, diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 0ba68d68e..79357164a 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -468,11 +468,14 @@ func (s *ManualCommitStrategy) PostCommit() error { return nil } - // Track shadow branches to clean up after successful condensation + // Track shadow branch names to clean up after successful condensation shadowBranchesToDelete := make(map[string]struct{}) // Condense sessions that have new content for _, state := range sessionsWithContent { + // Compute shadow branch name BEFORE condensation modifies state.BaseCommit + shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) + result, err := s.CondenseSession(repo, checkpointID, state) if err != nil { fmt.Fprintf(os.Stderr, "[entire] Warning: condensation failed for session %s: %v\n", @@ -481,7 +484,7 @@ func (s *ManualCommitStrategy) PostCommit() error { } // Track this shadow branch for cleanup - shadowBranchesToDelete[state.BaseCommit] = struct{}{} + shadowBranchesToDelete[shadowBranchName] = struct{}{} // Update session state for the new base commit // After condensation, the session continues from the NEW commit (HEAD), so we: @@ -529,8 +532,7 @@ func (s *ManualCommitStrategy) PostCommit() error { // Clean up shadow branches after successful condensation // Data is now preserved on entire/sessions, so shadow branches are no longer needed - for baseCommit := range shadowBranchesToDelete { - shadowBranchName := getShadowBranchNameForCommit(baseCommit) + for shadowBranchName := range shadowBranchesToDelete { if err := deleteShadowBranch(repo, shadowBranchName); err != nil { fmt.Fprintf(os.Stderr, "[entire] Warning: failed to clean up %s: %v\n", shadowBranchName, err) } else { @@ -572,7 +574,7 @@ func (s *ManualCommitStrategy) filterSessionsWithNewContent(repo *git.Repository // beyond what was already condensed. func (s *ManualCommitStrategy) sessionHasNewContent(repo *git.Repository, state *SessionState) (bool, error) { // Get shadow branch - shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit) + shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) ref, err := repo.Reference(refName, true) if err != nil { @@ -776,8 +778,8 @@ func addCheckpointTrailerWithComment(message string, checkpointID id.CheckpointI // If the session already exists and HEAD has moved (e.g., user committed), updates // BaseCommit to the new HEAD so future checkpoints go to the correct shadow branch. // -// If there's an existing shadow branch with activity from a different worktree, -// returns a ShadowBranchConflictError to allow the caller to inform the user. +// If there's an existing shadow branch with commits from a different session ID, +// returns a SessionIDConflictError to prevent orphaning existing session work. // // agentType is the human-readable name of the agent (e.g., "Claude Code"). // transcriptPath is the path to the live transcript file (for mid-session commit detection). @@ -793,6 +795,16 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age return fmt.Errorf("failed to get HEAD: %w", err) } + // Get current worktree info for shadow branch naming + worktreePath, err := GetWorktreePath() + if err != nil { + return fmt.Errorf("failed to get worktree path: %w", err) + } + currentWorktreeID, err := paths.GetWorktreeID(worktreePath) + if err != nil { + return fmt.Errorf("failed to get worktree ID: %w", err) + } + // Check if session already exists state, err := s.loadSessionState(sessionID) if err != nil { @@ -804,7 +816,7 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age // (e.g., state was created by concurrent warning but conflict later resolved) baseCommitHash := head.Hash().String() if state == nil || state.CheckpointCount == 0 { - shadowBranch := getShadowBranchNameForCommit(baseCommitHash) + shadowBranch := getShadowBranchNameForCommit(baseCommitHash, currentWorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranch) ref, refErr := repo.Reference(refName, true) @@ -828,7 +840,7 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age } } else { // Existing session has state - this is a real conflict - // (e.g., different worktree at same commit) + // (e.g., concurrent sessions in same directory) return &SessionIDConflictError{ ExistingSession: existingSessionID, NewSession: sessionID, @@ -880,11 +892,11 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age // Check if old shadow branch exists - if so, user did NOT commit (would have been deleted) // This happens when user does: stash → pull → stash apply, or rebase, etc. - oldShadowBranch := getShadowBranchNameForCommit(oldBaseCommit) + oldShadowBranch := getShadowBranchNameForCommit(oldBaseCommit, state.WorktreeID) oldRefName := plumbing.NewBranchReferenceName(oldShadowBranch) if oldRef, err := repo.Reference(oldRefName, true); err == nil { // Old shadow branch exists - move it to new base commit - newShadowBranch := getShadowBranchNameForCommit(newBaseCommit) + newShadowBranch := getShadowBranchNameForCommit(newBaseCommit, state.WorktreeID) newRefName := plumbing.NewBranchReferenceName(newShadowBranch) // Create new reference pointing to same commit @@ -918,36 +930,6 @@ func (s *ManualCommitStrategy) InitializeSession(sessionID string, agentType age // If state exists but BaseCommit is empty, it's a partial state from concurrent warning // Continue below to properly initialize it - currentWorktree, err := GetWorktreePath() - if err != nil { - return fmt.Errorf("failed to get worktree path: %w", err) - } - - // Check for existing sessions on the same base commit from different worktrees - existingSessions, err := s.findSessionsForCommit(head.Hash().String()) - if err != nil { - // Log but continue - conflict detection is best-effort - fmt.Fprintf(os.Stderr, "Warning: failed to check for existing sessions: %v\n", err) - } else { - for _, existingState := range existingSessions { - // Skip sessions from the same worktree - if existingState.WorktreePath == currentWorktree { - continue - } - - // Found a session from a different worktree on the same base commit - shadowBranch := getShadowBranchNameForCommit(head.Hash().String()) - return &ShadowBranchConflictError{ - Branch: shadowBranch, - ExistingSession: existingState.SessionID, - ExistingWorktree: existingState.WorktreePath, - LastActivity: existingState.StartedAt, - CurrentSession: sessionID, - CurrentWorktree: currentWorktree, - } - } - } - // Initialize new session state, err = s.initializeSession(repo, sessionID, agentType, transcriptPath) if err != nil { @@ -986,7 +968,7 @@ func (s *ManualCommitStrategy) calculatePromptAttributionAtStart( // For the first checkpoint, no shadow branch exists yet - this is fine, // CalculatePromptAttribution will use baseTree as the reference instead. var lastCheckpointTree *object.Tree - shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit) + shadowBranchName := checkpoint.ShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) ref, err := repo.Reference(refName, true) if err != nil { @@ -1103,7 +1085,7 @@ func getStagedFiles(repo *git.Repository) []string { // getLastPrompt retrieves the most recent user prompt from a session's shadow branch. // Returns empty string if no prompt can be retrieved. func (s *ManualCommitStrategy) getLastPrompt(repo *git.Repository, state *SessionState) string { - shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit) + shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) ref, err := repo.Reference(refName, true) if err != nil { diff --git a/cmd/entire/cli/strategy/manual_commit_logs.go b/cmd/entire/cli/strategy/manual_commit_logs.go index 0611564cb..874463b7e 100644 --- a/cmd/entire/cli/strategy/manual_commit_logs.go +++ b/cmd/entire/cli/strategy/manual_commit_logs.go @@ -49,7 +49,7 @@ func (s *ManualCommitStrategy) GetSessionInfo() (*SessionInfo, error) { // Return info for most recent session state := sessions[0] - shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit) + shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) info := &SessionInfo{ @@ -174,7 +174,7 @@ func (s *ManualCommitStrategy) GetAdditionalSessions() ([]*Session, error) { } // Try to get description from shadow branch - if description := s.getDescriptionFromShadowBranch(state.SessionID, state.BaseCommit); description != "" { + if description := s.getDescriptionFromShadowBranch(state.SessionID, state.BaseCommit, state.WorktreeID); description != "" { session.Description = description } @@ -186,13 +186,13 @@ func (s *ManualCommitStrategy) GetAdditionalSessions() ([]*Session, error) { // getDescriptionFromShadowBranch reads the session description from the shadow branch. // sessionID is expected to be an Entire session ID (already date-prefixed like "2026-01-12-abc123"). -func (s *ManualCommitStrategy) getDescriptionFromShadowBranch(sessionID, baseCommit string) string { +func (s *ManualCommitStrategy) getDescriptionFromShadowBranch(sessionID, baseCommit, worktreeID string) string { repo, err := OpenRepository() if err != nil { return "" } - shadowBranchName := getShadowBranchNameForCommit(baseCommit) + shadowBranchName := getShadowBranchNameForCommit(baseCommit, worktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) ref, err := repo.Reference(refName, true) if err != nil { diff --git a/cmd/entire/cli/strategy/manual_commit_reset.go b/cmd/entire/cli/strategy/manual_commit_reset.go index da1656697..cff42f253 100644 --- a/cmd/entire/cli/strategy/manual_commit_reset.go +++ b/cmd/entire/cli/strategy/manual_commit_reset.go @@ -4,6 +4,8 @@ import ( "fmt" "os" + "entire.io/cli/cmd/entire/cli/paths" + "github.com/charmbracelet/huh" "github.com/go-git/go-git/v5/plumbing" ) @@ -28,8 +30,18 @@ func (s *ManualCommitStrategy) Reset(force bool) error { return fmt.Errorf("failed to get HEAD: %w", err) } + // Get current worktree ID for shadow branch naming + worktreePath, err := GetWorktreePath() + if err != nil { + return fmt.Errorf("failed to get worktree path: %w", err) + } + worktreeID, err := paths.GetWorktreeID(worktreePath) + if err != nil { + return fmt.Errorf("failed to get worktree ID: %w", err) + } + // Get shadow branch name for current HEAD - shadowBranchName := getShadowBranchNameForCommit(head.Hash().String()) + shadowBranchName := getShadowBranchNameForCommit(head.Hash().String(), worktreeID) // Check if shadow branch exists refName := plumbing.NewBranchReferenceName(shadowBranchName) diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index 447688bdc..022ad3b27 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -57,7 +57,7 @@ func (s *ManualCommitStrategy) GetRewindPoints(limit int) ([]RewindPoint, error) sessionPrompts := make(map[string]string) for _, state := range sessions { - checkpoints, err := store.ListTemporaryCheckpoints(context.Background(), state.BaseCommit, state.SessionID, limit) + checkpoints, err := store.ListTemporaryCheckpoints(context.Background(), state.BaseCommit, state.WorktreeID, state.SessionID, limit) if err != nil { continue // Error reading checkpoints, skip this session } @@ -448,7 +448,7 @@ func (s *ManualCommitStrategy) resetShadowBranchToCheckpoint(repo *git.Repositor } // Reset the shadow branch to the checkpoint commit - shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit) + shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranchName) // Update the reference to point to the checkpoint commit diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index 0e80a563b..61d745b4b 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -7,6 +7,7 @@ import ( "entire.io/cli/cmd/entire/cli/agent" "entire.io/cli/cmd/entire/cli/checkpoint" + "entire.io/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -83,7 +84,7 @@ func (s *ManualCommitStrategy) listAllSessionStates() ([]*SessionState, error) { // AND has no LastCheckpointID (not recently condensed) // Sessions with LastCheckpointID are valid - they were condensed and the shadow // branch was intentionally deleted. Keep them for LastCheckpointID reuse. - shadowBranch := getShadowBranchNameForCommit(state.BaseCommit) + shadowBranch := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) refName := plumbing.NewBranchReferenceName(shadowBranch) if _, err := repo.Reference(refName, true); err != nil { // Shadow branch doesn't exist @@ -203,6 +204,12 @@ func (s *ManualCommitStrategy) initializeSession(repo *git.Repository, sessionID return nil, fmt.Errorf("failed to get worktree path: %w", err) } + // Get worktree ID for shadow branch naming + worktreeID, err := paths.GetWorktreeID(worktreePath) + if err != nil { + return nil, fmt.Errorf("failed to get worktree ID: %w", err) + } + // Capture untracked files at session start to preserve them during rewind untrackedFiles, err := collectUntrackedFiles() if err != nil { @@ -222,6 +229,7 @@ func (s *ManualCommitStrategy) initializeSession(repo *git.Repository, sessionID SessionID: sessionID, BaseCommit: head.Hash().String(), WorktreePath: worktreePath, + WorktreeID: worktreeID, StartedAt: time.Now(), CheckpointCount: 0, UntrackedFilesAtStart: untrackedFiles, @@ -237,10 +245,8 @@ func (s *ManualCommitStrategy) initializeSession(repo *git.Repository, sessionID return state, nil } -// getShadowBranchNameForCommit returns the shadow branch name for the given base commit. -func getShadowBranchNameForCommit(baseCommit string) string { - if len(baseCommit) >= checkpoint.ShadowBranchHashLength { - return shadowBranchPrefix + baseCommit[:checkpoint.ShadowBranchHashLength] - } - return shadowBranchPrefix + baseCommit +// getShadowBranchNameForCommit returns the shadow branch name for the given base commit and worktree ID. +// worktreeID should be empty for the main worktree or the internal git worktree name for linked worktrees. +func getShadowBranchNameForCommit(baseCommit, worktreeID string) string { + return checkpoint.ShadowBranchNameForCommit(baseCommit, worktreeID) } diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index dbddbd31a..71d55cbee 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -10,6 +10,7 @@ import ( "time" "entire.io/cli/cmd/entire/cli/agent" + "entire.io/cli/cmd/entire/cli/checkpoint" "entire.io/cli/cmd/entire/cli/checkpoint/id" "entire.io/cli/cmd/entire/cli/paths" "entire.io/cli/cmd/entire/cli/trailers" @@ -163,7 +164,8 @@ func TestShadowStrategy_ListAllSessionStates(t *testing.T) { } // Create shadow branch for base commit "abc1234" (needs 7 chars for prefix) - shadowBranch := getShadowBranchNameForCommit("abc1234") + // Use empty worktreeID since this is simulating the main worktree + shadowBranch := getShadowBranchNameForCommit("abc1234", "") refName := plumbing.NewBranchReferenceName(shadowBranch) ref := plumbing.NewHashReference(refName, dummyCommitHash) if err := repo.Storer.SetReference(ref); err != nil { @@ -221,8 +223,9 @@ func TestShadowStrategy_FindSessionsForCommit(t *testing.T) { } // Create shadow branches for base commits "abc1234" and "xyz7890" (7 chars) + // Use empty worktreeID since this is simulating the main worktree for _, baseCommit := range []string{"abc1234", "xyz7890"} { - shadowBranch := getShadowBranchNameForCommit(baseCommit) + shadowBranch := getShadowBranchNameForCommit(baseCommit, "") refName := plumbing.NewBranchReferenceName(shadowBranch) ref := plumbing.NewHashReference(refName, dummyCommitHash) if err := repo.Storer.SetReference(ref); err != nil { @@ -603,33 +606,46 @@ func TestShadowStrategy_GetTaskCheckpointTranscript_NotTaskCheckpoint(t *testing } func TestGetShadowBranchNameForCommit(t *testing.T) { + // Hash of empty worktreeID (main worktree) is "e3b0c4" + mainWorktreeHash := "e3b0c4" + tests := []struct { name string baseCommit string + worktreeID string want string }{ { - name: "short commit", + name: "short commit main worktree", baseCommit: "abc", - want: "entire/abc", + worktreeID: "", + want: "entire/abc-" + mainWorktreeHash, }, { - name: "7 char commit", + name: "7 char commit main worktree", baseCommit: "abc1234", - want: "entire/abc1234", + worktreeID: "", + want: "entire/abc1234-" + mainWorktreeHash, }, { - name: "long commit", + name: "long commit main worktree", baseCommit: "abc1234567890", - want: "entire/abc1234", + worktreeID: "", + want: "entire/abc1234-" + mainWorktreeHash, + }, + { + name: "with linked worktree", + baseCommit: "abc1234", + worktreeID: "feature-branch", + want: "entire/abc1234-" + checkpoint.HashWorktreeID("feature-branch"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getShadowBranchNameForCommit(tt.baseCommit) + got := getShadowBranchNameForCommit(tt.baseCommit, tt.worktreeID) if got != tt.want { - t.Errorf("getShadowBranchNameForCommit(%q) = %q, want %q", tt.baseCommit, got, tt.want) + t.Errorf("getShadowBranchNameForCommit(%q, %q) = %q, want %q", tt.baseCommit, tt.worktreeID, got, tt.want) } }) } @@ -1400,7 +1416,7 @@ func TestShadowStrategy_CondenseSession_EphemeralBranchTrailer(t *testing.T) { } // Verify the commit message contains the Ephemeral-branch trailer - shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit) + shadowBranchName := getShadowBranchNameForCommit(state.BaseCommit, state.WorktreeID) expectedTrailer := "Ephemeral-branch: " + shadowBranchName if !strings.Contains(sessionsCommit.Message, expectedTrailer) { t.Errorf("sessions branch commit should contain %q trailer, got message:\n%s", expectedTrailer, sessionsCommit.Message) diff --git a/cmd/entire/cli/strategy/manual_commit_types.go b/cmd/entire/cli/strategy/manual_commit_types.go index f920c81d1..9df3ba79f 100644 --- a/cmd/entire/cli/strategy/manual_commit_types.go +++ b/cmd/entire/cli/strategy/manual_commit_types.go @@ -20,6 +20,7 @@ type SessionState struct { SessionID string `json:"session_id"` BaseCommit string `json:"base_commit"` WorktreePath string `json:"worktree_path,omitempty"` // Absolute path to the worktree root + WorktreeID string `json:"worktree_id,omitempty"` // Internal git worktree identifier (empty for main worktree) StartedAt time.Time `json:"started_at"` CheckpointCount int `json:"checkpoint_count"` CondensedTranscriptLines int `json:"condensed_transcript_lines,omitempty"` // Lines already included in previous condensation diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 49d7568ef..69823e597 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -25,21 +25,6 @@ var ErrNotTaskCheckpoint = errors.New("not a task checkpoint") // ErrNotImplemented is returned when a feature is not yet implemented. var ErrNotImplemented = errors.New("not implemented") -// ShadowBranchConflictError is returned when a shadow branch already exists -// with activity from a different session or worktree. -type ShadowBranchConflictError struct { - Branch string // The shadow branch name (e.g., "entire/abc1234") - ExistingSession string // Session ID of the existing session - ExistingWorktree string // Worktree path of the existing session - LastActivity time.Time // When the existing session was last active - CurrentSession string // Session ID of the current session attempting to start - CurrentWorktree string // Worktree path of the current session -} - -func (e *ShadowBranchConflictError) Error() string { - return "shadow branch conflict: existing session from different worktree" -} - // SessionIDConflictError is returned when trying to start a new session // but the shadow branch already has commits from a different session ID. // This prevents orphaning existing session work.