From d304af39586355e3aa1ee1763d7ada9f895debda Mon Sep 17 00:00:00 2001 From: fahd Date: Fri, 13 Feb 2026 15:36:23 +0100 Subject: [PATCH] feat: add import-session command for manual Claude Code session import Implements #287. Allows importing Claude Code JSONL transcripts into Entire checkpoints, useful for: - Recovering sessions that weren't checkpointed (e.g., due to bugs) - Adopting Entire in repos with existing sessions Usage: entire import-session path/to/session.jsonl [path2.jsonl ...] entire import-session session.jsonl --commit Metadata is written to entire/checkpoints/v1. The command prints instructions for adding the Entire-Checkpoint trailer to link the commit to the checkpoint. When targeting a past commit, warns about history rewrite and force push. --- cmd/entire/cli/import_session.go | 207 ++++++++++++++++++ .../integration_test/import_session_test.go | 156 +++++++++++++ cmd/entire/cli/integration_test/testenv.go | 1 + cmd/entire/cli/root.go | 1 + 4 files changed, 365 insertions(+) create mode 100644 cmd/entire/cli/import_session.go create mode 100644 cmd/entire/cli/integration_test/import_session_test.go diff --git a/cmd/entire/cli/import_session.go b/cmd/entire/cli/import_session.go new file mode 100644 index 000000000..7195adf73 --- /dev/null +++ b/cmd/entire/cli/import_session.go @@ -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//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 +} diff --git a/cmd/entire/cli/integration_test/import_session_test.go b/cmd/entire/cli/integration_test/import_session_test.go new file mode 100644 index 000000000..00a50bfab --- /dev/null +++ b/cmd/entire/cli/integration_test/import_session_test.go @@ -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") + } +} diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index 297e56e58..227365b4a 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -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) diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 5fedf6ad4..2299ad411 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -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())