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
207 changes: 207 additions & 0 deletions cmd/entire/cli/import_session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package cli

import (
"context"
"errors"
"fmt"
"os"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/trailers"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/spf13/cobra"
)

func newImportSessionCmd() *cobra.Command {
var commitFlag string

cmd := &cobra.Command{
Use: "import-session",
Short: "Import Claude Code session transcript(s) into Entire checkpoints",
Long: `Import Claude Code session transcript(s) into Entire checkpoints.

Use this to recover sessions that were not properly checkpointed (e.g., due to bugs)
or to import existing sessions when adopting Entire in an existing repository.

Each argument should be a path to a Claude Code JSONL transcript file (e.g., from
~/.claude/projects/<project>/sessions/*.jsonl).

By default, imports are associated with HEAD. Use --commit to target a specific commit.
When targeting a past commit, you will need to amend that commit to add the
Entire-Checkpoint trailer, which rewrites history and may require a force push.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runImportSession(cmd, args, commitFlag)
},
}

cmd.Flags().StringVar(&commitFlag, "commit", "", "Target commit (hash or ref) to associate the checkpoint with. Default is HEAD.")

return cmd
}

func runImportSession(cmd *cobra.Command, sessionPaths []string, targetCommit string) error {
ctx := context.Background()

// Must be in a git repository
repoRoot, err := paths.RepoRoot()
if err != nil {
cmd.SilenceUsage = true
return NewSilentError(fmt.Errorf("not a git repository: %w", err))
}

repo, err := strategy.OpenRepository()
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}

// Entire must be enabled
s, err := settings.Load()
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
if !s.Enabled {
cmd.SilenceUsage = true
return NewSilentError(errors.New("entire is not enabled. Run 'entire enable' first"))
}

// Resolve target commit
hash, err := resolveCommit(repo, targetCommit)
if err != nil {
return fmt.Errorf("invalid --commit %q: %w", targetCommit, err)
}

// Get git author and branch for metadata
authorName, authorEmail := strategy.GetGitAuthorFromRepo(repo)
branchName := strategy.GetCurrentBranchName(repo)

// Determine strategy name from settings
strat := GetStrategy()
strategyName := strat.Name()

// Generate single checkpoint ID for this import (multi-session if multiple files)
checkpointID, err := id.Generate()
if err != nil {
return fmt.Errorf("failed to generate checkpoint ID: %w", err)
}

// Import each session file
store := checkpoint.NewGitStore(repo)
for i, sessionPath := range sessionPaths {
if err := importOneSession(ctx, store, importSessionOpts{
sessionPath: sessionPath,
checkpointID: checkpointID,
sessionIndex: i,
authorName: authorName,
authorEmail: authorEmail,
strategyName: strategyName,
branchName: branchName,
repoRoot: repoRoot,
}); err != nil {
return fmt.Errorf("import %q: %w", sessionPath, err)
}
}

// Print success and instructions
fmt.Fprintln(cmd.OutOrStdout(), "Imported", len(sessionPaths), "session(s) to checkpoint", checkpointID.String(), "on", hash.String()[:7])

// Check if target commit has an Entire-Checkpoint trailer
commitObj, err := repo.CommitObject(hash)
if err != nil {
return fmt.Errorf("failed to get commit: %w", err)
}
_, alreadyHasTrailer := trailers.ParseCheckpoint(commitObj.Message)

if alreadyHasTrailer {
fmt.Fprintf(cmd.OutOrStdout(), "Commit %s already has an Entire-Checkpoint trailer.\n", hash.String()[:7])
} else {
fmt.Fprintln(cmd.OutOrStdout())
fmt.Fprintln(cmd.OutOrStdout(), "To link this checkpoint to the commit, add the trailer:")
if targetCommit == "" || targetCommit == "HEAD" {
fmt.Fprintf(cmd.OutOrStdout(), " git commit --amend -m \"$(git log -1 --format='%%B')\n%s: %s\"\n", trailers.CheckpointTrailerKey, checkpointID)
} else {
fmt.Fprintf(cmd.OutOrStdout(), " # Use interactive rebase to amend commit %s, then add:\n", hash.String()[:7])
fmt.Fprintf(cmd.OutOrStdout(), " # %s: %s\n", trailers.CheckpointTrailerKey, checkpointID)
fmt.Fprintln(cmd.OutOrStdout())
fmt.Fprintln(cmd.OutOrStdout(), "Caution: Amending a past commit rewrites git history. You may need to force push to share with contributors.")
}
}

return nil
}

type importSessionOpts struct {
sessionPath string
checkpointID id.CheckpointID
sessionIndex int
authorName string
authorEmail string
strategyName string
branchName string
repoRoot string
}

func importOneSession(ctx context.Context, store *checkpoint.GitStore, opts importSessionOpts) error {
data, err := os.ReadFile(opts.sessionPath)
if err != nil {
return fmt.Errorf("failed to read transcript: %w", err)
}

lines, err := claudecode.ParseTranscript(data)
if err != nil {
return fmt.Errorf("invalid Claude Code JSONL transcript: %w", err)
}

// Extract modified files and last user prompt
modifiedFiles := claudecode.ExtractModifiedFiles(lines)
lastPrompt := claudecode.ExtractLastUserPrompt(lines)

// Normalize paths to repo-relative (required for checkpoint metadata)
modifiedFiles = FilterAndNormalizePaths(modifiedFiles, opts.repoRoot)

// Generate session ID - must be path-safe per validation
sessionID := fmt.Sprintf("import-%s-%d", opts.checkpointID, opts.sessionIndex)

// Build prompts slice (one entry for imported sessions)
var prompts []string
if lastPrompt != "" {
prompts = []string{lastPrompt}
}

if err := store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{
CheckpointID: opts.checkpointID,
SessionID: sessionID,
Strategy: opts.strategyName,
Branch: opts.branchName,
Transcript: data,
Prompts: prompts,
Context: nil, // Import doesn't have context.md
FilesTouched: modifiedFiles,
CheckpointsCount: 1,
AuthorName: opts.authorName,
AuthorEmail: opts.authorEmail,
Agent: agent.AgentTypeClaudeCode,
}); err != nil {
return fmt.Errorf("write committed: %w", err)
}
return nil
}

func resolveCommit(repo *git.Repository, ref string) (plumbing.Hash, error) {
if ref == "" {
ref = "HEAD"
}
h, err := repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("resolve revision: %w", err)
}
return *h, nil
}
156 changes: 156 additions & 0 deletions cmd/entire/cli/integration_test/import_session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//go:build integration

package integration

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

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/strategy"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)

func TestImportSession_ImportToHEAD(t *testing.T) {
t.Parallel()
env := NewTestEnv(t)
defer env.Cleanup()

env.InitRepo()
env.WriteFile("README.md", "# Test")
env.GitAdd("README.md")
env.GitCommit("Initial commit")
env.InitEntire(strategy.StrategyNameManualCommit)

// Create a valid Claude Code JSONL transcript
transcriptContent := `{"type":"user","uuid":"u1","message":{"content":"Add a hello function"}}
{"type":"assistant","uuid":"a1","message":{"content":[{"type":"tool_use","id":"t1","name":"Write","input":{"file_path":"hello.go","contents":"package main\n\nfunc Hello() string { return \"hi\" }"}}]}}
{"type":"user","uuid":"u2","message":{"content":[{"type":"tool_result","tool_use_id":"t1"}]}}
`
env.WriteFile("session.jsonl", transcriptContent)
// WriteFile creates repo-relative - we need abs path for CLI
absPath := filepath.Join(env.RepoDir, "session.jsonl")

output := env.RunCLI("import-session", absPath)

if !strings.Contains(output, "Imported 1 session") {
t.Errorf("expected 'Imported 1 session' in output, got:\n%s", output)
}
if !strings.Contains(output, "To link this checkpoint to the commit") {
t.Errorf("expected trailer instructions in output, got:\n%s", output)
}
if !strings.Contains(output, "Entire-Checkpoint") {
t.Errorf("expected Entire-Checkpoint in output, got:\n%s", output)
}

// Verify checkpoint exists on entire/checkpoints/v1
repo, err := git.PlainOpen(env.RepoDir)
if err != nil {
t.Fatalf("failed to open repo: %v", err)
}
_, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
if err != nil {
t.Fatalf("metadata branch not found: %v", err)
}

store := checkpoint.NewGitStore(repo)
committed, err := store.ListCommitted(env.T.Context())
if err != nil {
t.Fatalf("ListCommitted failed: %v", err)
}
if len(committed) != 1 {
t.Fatalf("expected 1 committed checkpoint, got %d", len(committed))
}

// Verify we can read the transcript
content, err := store.ReadSessionContent(env.T.Context(), committed[0].CheckpointID, 0)
if err != nil {
t.Fatalf("ReadSessionContent failed: %v", err)
}
if !strings.Contains(string(content.Transcript), "Add a hello function") {
t.Errorf("transcript should contain prompt, got: %s", string(content.Transcript)[:min(100, len(content.Transcript))])
}
if len(content.Metadata.FilesTouched) == 0 {
t.Error("expected files_touched to include hello.go")
}
}

func TestImportSession_MultipleSessions(t *testing.T) {
t.Parallel()
env := NewTestEnv(t)
defer env.Cleanup()

env.InitRepo()
env.WriteFile("README.md", "# Test")
env.GitAdd("README.md")
env.GitCommit("Initial commit")
env.InitEntire(strategy.StrategyNameManualCommit)

transcript1 := `{"type":"user","uuid":"u1","message":{"content":"First task"}}
{"type":"assistant","uuid":"a1","message":{"content":[]}}
`
transcript2 := `{"type":"user","uuid":"u2","message":{"content":"Second task"}}
{"type":"assistant","uuid":"a2","message":{"content":[]}}
`
env.WriteFile("s1.jsonl", transcript1)
env.WriteFile("s2.jsonl", transcript2)
s1Path := filepath.Join(env.RepoDir, "s1.jsonl")
s2Path := filepath.Join(env.RepoDir, "s2.jsonl")

output := env.RunCLI("import-session", s1Path, s2Path)

if !strings.Contains(output, "Imported 2 session(s)") {
t.Errorf("expected 'Imported 2 session(s)', got:\n%s", output)
}

// Should have one checkpoint with two sessions
repo, err := git.PlainOpen(env.RepoDir)
if err != nil {
t.Fatalf("failed to open repo: %v", err)
}
store := checkpoint.NewGitStore(repo)
committed, err := store.ListCommitted(env.T.Context())
if err != nil {
t.Fatalf("ListCommitted failed: %v", err)
}
if len(committed) != 1 {
t.Fatalf("expected 1 checkpoint (with 2 sessions), got %d checkpoints", len(committed))
}

// Read both sessions
for i := 0; i < 2; i++ {
content, err := store.ReadSessionContent(env.T.Context(), committed[0].CheckpointID, i)
if err != nil {
t.Fatalf("ReadSessionContent(%d) failed: %v", i, err)
}
if len(content.Transcript) == 0 {
t.Errorf("session %d has empty transcript", i)
}
}
}

func TestImportSession_RequiresEnabled(t *testing.T) {
t.Parallel()
env := NewTestEnv(t)
defer env.Cleanup()

env.InitRepo()
env.WriteFile("README.md", "# Test")
env.GitAdd("README.md")
env.GitCommit("Initial commit")

// Create .entire with enabled: false (don't use InitEntire which sets enabled: true)
env.WriteFile(".entire/settings.json", `{"strategy": "manual-commit", "enabled": false}`)

transcriptPath := filepath.Join(env.RepoDir, "session.jsonl")
env.WriteFile("session.jsonl", `{"type":"user","uuid":"u1","message":{"content":"test"}}`)

_, err := env.RunCLIWithError("import-session", transcriptPath)
if err == nil {
t.Error("expected import-session to fail when Entire is disabled")
}
}
1 change: 1 addition & 0 deletions cmd/entire/cli/integration_test/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ func (env *TestEnv) initEntireInternal(strategyName string, agentName agent.Agen
// Write settings.json
settings := map[string]any{
"strategy": strategyName,
"enabled": true,
"local_dev": true, // Use go run for hooks in tests
}
// Only add agent if specified (otherwise defaults to claude-code)
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func NewRootCmd() *cobra.Command {
cmd.AddCommand(newHooksCmd())
cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newExplainCmd())
cmd.AddCommand(newImportSessionCmd())
cmd.AddCommand(newDebugCmd())
cmd.AddCommand(newDoctorCmd())
cmd.AddCommand(newSendAnalyticsCmd())
Expand Down