Skip to content
Open
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
25 changes: 13 additions & 12 deletions cmd/entire/cli/agent/geminicli/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ package geminicli

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"time"

"github.com/entireio/cli/cmd/entire/cli/agent"
Expand Down Expand Up @@ -162,7 +163,7 @@ func (g *GeminiCLIAgent) ProtectedDirs() []string { return []string{".gemini"} }
// ResolveSessionFile returns the path to a Gemini session file.
// Gemini names files as session-<date>-<shortid>.json where shortid is the first 8 chars
// of the session UUID. This searches for an existing file matching the pattern, falling
// back to <dir>/<id>.json if no match is found.
// back to constructing a filename matching Gemini's convention if no match is found.
func (g *GeminiCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
// Try to find existing file matching Gemini's naming convention:
// session-*-<first8chars>.json
Expand All @@ -178,8 +179,9 @@ func (g *GeminiCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) s
return matches[len(matches)-1]
}

// Fallback: construct a default path
return filepath.Join(sessionDir, agentSessionID+".json")
// Fallback: construct filename matching Gemini's convention: session-<timestamp>-<id[:8]>.json
timestamp := time.Now().UTC().Format("2006-01-02T15-04")
return filepath.Join(sessionDir, "session-"+timestamp+"-"+shortID+".json")
}

// GetSessionDir returns the directory where Gemini stores session transcripts.
Expand All @@ -195,8 +197,8 @@ func (g *GeminiCLIAgent) GetSessionDir(repoPath string) (string, error) {
return "", fmt.Errorf("failed to get home directory: %w", err)
}

// Gemini uses a hash of the project path for the directory name
projectDir := SanitizePathForGemini(repoPath)
// Gemini uses a SHA256 hash of the project path for the directory name
projectDir := GetProjectHash(repoPath)
return filepath.Join(homeDir, ".gemini", "tmp", projectDir, "chats"), nil
}

Expand Down Expand Up @@ -263,12 +265,11 @@ func (g *GeminiCLIAgent) FormatResumeCommand(sessionID string) string {
return "gemini --resume " + sessionID
}

// SanitizePathForGemini converts a path to Gemini's project directory format.
// Gemini uses a hash-like sanitization similar to Claude.
var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`)

func SanitizePathForGemini(path string) string {
return nonAlphanumericRegex.ReplaceAllString(path, "-")
// GetProjectHash generates a unique hash for a project based on its root path.
// This matches Gemini CLI's getProjectHash() which uses SHA256 of the project root.
func GetProjectHash(projectRoot string) string {
hash := sha256.Sum256([]byte(projectRoot))
return hex.EncodeToString(hash[:])
}

// TranscriptAnalyzer interface implementation
Expand Down
55 changes: 34 additions & 21 deletions cmd/entire/cli/agent/geminicli/gemini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,15 +245,21 @@ func TestResolveSessionFile(t *testing.T) {
}
})

t.Run("falls back to default when no match", func(t *testing.T) {
t.Run("falls back to Gemini-style filename when no match", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
ag := &GeminiCLIAgent{}

result := ag.ResolveSessionFile(tmpDir, "0544a0f5-46a6-41b3-a89c-e7804df731b8")
expected := filepath.Join(tmpDir, "0544a0f5-46a6-41b3-a89c-e7804df731b8.json")
if result != expected {
t.Errorf("ResolveSessionFile() = %q, want %q", result, expected)
filename := filepath.Base(result)
if !strings.HasPrefix(filename, "session-") {
t.Errorf("fallback filename %q should start with 'session-'", filename)
}
if !strings.HasSuffix(filename, "-0544a0f5.json") {
t.Errorf("fallback filename %q should end with '-0544a0f5.json'", filename)
}
if filepath.Dir(result) != tmpDir {
t.Errorf("fallback dir = %q, want %q", filepath.Dir(result), tmpDir)
}
})

Expand All @@ -262,11 +268,14 @@ func TestResolveSessionFile(t *testing.T) {
tmpDir := t.TempDir()
ag := &GeminiCLIAgent{}

// Short ID (less than 8 chars) should use entire ID as prefix
// Short ID (less than 8 chars) should use entire ID in filename
result := ag.ResolveSessionFile(tmpDir, "abc123")
expected := filepath.Join(tmpDir, "abc123.json")
if result != expected {
t.Errorf("ResolveSessionFile() = %q, want %q", result, expected)
filename := filepath.Base(result)
if !strings.HasPrefix(filename, "session-") {
t.Errorf("fallback filename %q should start with 'session-'", filename)
}
if !strings.HasSuffix(filename, "-abc123.json") {
t.Errorf("fallback filename %q should end with '-abc123.json'", filename)
}
})
}
Expand Down Expand Up @@ -441,21 +450,25 @@ func TestWriteSession_NoNativeData(t *testing.T) {
}
}

func TestSanitizePathForGemini(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/Users/test/project", "-Users-test-project"},
{"simple", "simple"},
{"/path/with spaces/dir", "-path-with-spaces-dir"},
func TestGetProjectHash(t *testing.T) {
t.Parallel()

// GetProjectHash should return a consistent SHA256 hex string for a given path
hash1 := GetProjectHash("/Users/test/project")
hash2 := GetProjectHash("/Users/test/project")
if hash1 != hash2 {
t.Errorf("GetProjectHash should be deterministic: got %q and %q", hash1, hash2)
}

for _, tt := range tests {
got := SanitizePathForGemini(tt.input)
if got != tt.want {
t.Errorf("SanitizePathForGemini(%q) = %q, want %q", tt.input, got, tt.want)
}
// Should be a 64-char hex string (SHA256)
if len(hash1) != 64 {
t.Errorf("GetProjectHash should return 64-char hex string, got %d chars: %q", len(hash1), hash1)
}

// Different paths should produce different hashes
hash3 := GetProjectHash("/Users/test/other")
if hash1 == hash3 {
t.Errorf("GetProjectHash should return different hashes for different paths")
}
}

Expand Down
10 changes: 0 additions & 10 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,6 @@ type WriteCommittedOptions struct {
// - the transcript was empty or too short to summarize
// - the checkpoint predates the summarization feature
Summary *Summary

// SessionTranscriptPath is the home-relative path to the session transcript file.
// Persisted in CommittedMetadata so restore can write the transcript back to
// the correct location without reconstructing agent-specific paths.
SessionTranscriptPath string
}

// UpdateCommittedOptions contains options for updating an existing committed checkpoint.
Expand Down Expand Up @@ -402,11 +397,6 @@ type CommittedMetadata struct {

// InitialAttribution is line-level attribution calculated at commit time
InitialAttribution *InitialAttribution `json:"initial_attribution,omitempty"`

// TranscriptPath is the home-relative path to the session transcript file.
// Persisted so restore can write the transcript back to the correct location
// without needing to reconstruct agent-specific paths (e.g. SHA-256 hashed dirs for Gemini).
TranscriptPath string `json:"transcript_path,omitempty"`
}

// GetTranscriptStart returns the transcript line offset at which this checkpoint's data begins.
Expand Down
1 change: 0 additions & 1 deletion cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,6 @@ func (s *GitStore) writeSessionToSubdirectory(opts WriteCommittedOptions, sessio
InitialAttribution: opts.InitialAttribution,
Summary: redactSummary(opts.Summary),
CLIVersion: buildinfo.Version,
TranscriptPath: opts.SessionTranscriptPath,
}

metadataJSON, err := jsonutil.MarshalIndentWithNewline(sessionMetadata, "", " ")
Expand Down
140 changes: 140 additions & 0 deletions cmd/entire/cli/e2e_test/resume_relocated_repo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//go:build e2e

package e2e

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestE2E_ResumeInRelocatedRepo verifies that entire resume works when a repository
// is moved to a different location after checkpoint creation. This validates that
// transcript paths are computed from the current repo location, not stored paths
// from checkpoint creation time.
//
// The test demonstrates that restore is location-independent by:
// 1. Creating a checkpoint at original location
// 2. Moving the repo to a new location (different directory hierarchy)
// 3. Running entire resume in the new location
// 4. Verifying the transcript was written to the NEW location's session dir
// 5. Verifying the OLD location's session dir was NOT created
func TestE2E_ResumeInRelocatedRepo(t *testing.T) {
t.Parallel()

// Create an initial test environment at the original location
env := NewFeatureBranchEnv(t, "manual-commit")
originalDir := env.RepoDir

t.Logf("Original repo location: %s", originalDir)

// Step 1: Agent creates a file
t.Log("Step 1: Running agent to create checkpoint")
result, err := env.RunAgent(PromptCreateHelloGo.Prompt)
require.NoError(t, err, "Agent should succeed")
AssertAgentSuccess(t, result, err)

// Step 2: Verify file was created
t.Log("Step 2: Verifying file was created")
require.True(t, env.FileExists("hello.go"), "hello.go should exist")

// Step 3: User commits with hooks to create checkpoint
t.Log("Step 3: Committing with hooks to create checkpoint")
env.GitCommitWithShadowHooks("Add hello world program", "hello.go")

// Step 4: Verify checkpoint exists
t.Log("Step 4: Verifying checkpoint was created")
checkpointID, err := env.GetLatestCheckpointIDFromHistory()
require.NoError(t, err, "Should find checkpoint in commit history")
require.NotEmpty(t, checkpointID, "Commit should have Entire-Checkpoint trailer")
t.Logf("Checkpoint ID: %s", checkpointID)

// Compute the expected session directories for original and new locations.
// Claude stores transcripts at ~/.claude/projects/<sanitized-repo-path>/
home, err := os.UserHomeDir()
require.NoError(t, err)

originalProjectDir := claudecode.SanitizePathForClaude(originalDir)
originalSessionDir := filepath.Join(home, ".claude", "projects", originalProjectDir)

// Step 5: Move the repository to a new location
t.Log("Step 5: Moving repository to new location")
tempBase := t.TempDir()
// Resolve symlinks (macOS: /var -> /private/var) to match what the CLI sees
if resolved, err := filepath.EvalSymlinks(tempBase); err == nil {
tempBase = resolved
}
newDir := filepath.Join(tempBase, "relocated", "new-location", "test-repo")
require.NoError(t, os.MkdirAll(filepath.Dir(newDir), 0o755))
require.NoError(t, os.Rename(originalDir, newDir), "should be able to move repo")

_, err = os.Stat(originalDir)
require.True(t, os.IsNotExist(err), "original location should not exist after move")
t.Logf("Moved repo to: %s", newDir)

newProjectDir := claudecode.SanitizePathForClaude(newDir)
newSessionDir := filepath.Join(home, ".claude", "projects", newProjectDir)

// Sanity check: the two project dirs must be different
require.NotEqual(t, originalProjectDir, newProjectDir,
"sanitized project dirs should differ for different repo paths")
t.Logf("Original session dir: %s", originalSessionDir)
t.Logf("New session dir: %s", newSessionDir)

// Clean up new session dir if it somehow already exists (idempotent test)
_ = os.RemoveAll(newSessionDir)

// Step 6: Create new environment pointing at the moved repo
t.Log("Step 6: Opening repo at new location")
newEnv := &TestEnv{
T: t,
RepoDir: newDir,
Agent: env.Agent,
}

// Step 7: Run entire resume in the new location
// resume requires a branch name argument: entire resume <branch> [--force]
t.Log("Step 7: Running 'entire resume feature/e2e-test --force' in new location")
output := newEnv.RunCLI("resume", "feature/e2e-test", "--force")
t.Logf("Resume output:\n%s", output)

// Step 8: Verify the transcript was written to the NEW session directory
t.Log("Step 8: Verifying session was created at new location")

// The resume output should reference the new session dir, not the old one
assert.Contains(t, output, newProjectDir,
"resume output should reference the new project directory")
assert.NotContains(t, output, originalProjectDir,
"resume output should NOT reference the old project directory")

// Verify the new session dir was actually created with files
newDirEntries, err := os.ReadDir(newSessionDir)
require.NoError(t, err, "new session directory should exist after resume")
require.NotEmpty(t, newDirEntries, "new session directory should contain files")
t.Logf("New session dir contains %d entries", len(newDirEntries))

// Verify at least one JSONL transcript file exists
hasTranscript := false
for _, entry := range newDirEntries {
if strings.HasSuffix(entry.Name(), ".jsonl") {
info, statErr := entry.Info()
if statErr == nil && info.Size() > 0 {
hasTranscript = true
t.Logf("Found transcript: %s (%d bytes)", entry.Name(), info.Size())
}
}
}
assert.True(t, hasTranscript, "new session directory should contain a non-empty JSONL transcript")

// Step 9: Verify the OLD session directory was NOT created by resume
// (It may exist from Step 1's agent run, so check that resume didn't write to it
// by checking the output doesn't reference it — already asserted above.)
t.Log("Step 9: Verified old session directory was not used by resume")

t.Log("Test passed: entire resume correctly uses computed session path from new repo location")
}
18 changes: 0 additions & 18 deletions cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,24 +238,6 @@ var (
protectedDirsCache []string
)

// homeRelativePath strips the $HOME/ prefix from an absolute path,
// returning a home-relative path suitable for persisting in metadata.
// Returns "" if the path is empty or not under $HOME.
func homeRelativePath(absPath string) string {
if absPath == "" {
return ""
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return ""
}
prefix := home + string(filepath.Separator)
if !strings.HasPrefix(absPath, prefix) {
return ""
}
return absPath[len(prefix):]
}

// isSpecificAgentType returns true if the agent type is a known, specific value
// (not empty and not the generic "Agent" fallback).
func isSpecificAgentType(t agent.AgentType) bool {
Expand Down
1 change: 0 additions & 1 deletion cmd/entire/cli/strategy/manual_commit_condensation.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI
TokenUsage: sessionData.TokenUsage,
InitialAttribution: attribution,
Summary: summary,
SessionTranscriptPath: homeRelativePath(state.TranscriptPath),
}); err != nil {
return nil, fmt.Errorf("failed to write checkpoint metadata: %w", err)
}
Expand Down
Loading