feat: adopt sessions across worktrees · Entire
feat: adopt sessions across worktrees
Sessions
Changes
3
- cmd/entire/cli
- Asession_adopt.go+260
- Asession_adopt_test.go+218
- Msessions.go+3
package cli
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/versioninfo"
"github.com/spf13/cobra"
)
type adoptOptions struct {
FromWorktree string
Force bool
}
const adoptRecentWindow = 12 * time.Hour
func newAdoptCmd() *cobra.Command {
var opts adoptOptions
cmd := &cobra.Command{
Use: "adopt [session-id]",
Short: "Adopt an active session from another worktree",
Long: `Adopt an active session from another worktree into the current repository.
This is useful when an agent starts in one repository or worktree, then moves
and makes changes in another. Adoption copies the live session state into the
current repo and seeds it with the current repo's uncommitted file changes so
the next commit can be linked normally.`,
Example: ` entire session adopt 019ed5fe-ec49-7a72-89fd-f38e323f5448 --from ../cli
entire session adopt --from /path/to/source/worktree`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
sessionID := ""
if len(args) > 0 {
sessionID = args[0]
}
return runAdopt(cmd.Context(), cmd.OutOrStdout(), sessionID, opts)
},
}
cmd.Flags().StringVar(&opts.FromWorktree, "from", "", "source worktree that already tracks the session")
cmd.Flags().BoolVar(&opts.Force, "force", false, "replace an existing local state file for the same session")
cmd.Flags().BoolVar(&opts.Force, "yes", false, "replace an existing local state file for the same session")
return cmd
}
func runAdopt(ctx context.Context, w io.Writer, sessionID string, opts adoptOptions) error {
if strings.TrimSpace(opts.FromWorktree) == "" {
return errors.New("source worktree is required; pass --from <path>")
}
sourceStore, sourceWorktree, err := stateStoreForWorktree(ctx, opts.FromWorktree)
if err != nil {
return err
}
sourceState, err := selectAdoptSourceSession(ctx, sourceStore, sourceWorktree, sessionID)
if err != nil {
return err
}
adopted, filesTouched, err := buildAdoptedSessionState(ctx, sourceState)
if err != nil {
return err
}
targetStore, err := session.NewStateStore(ctx)
if err != nil {
return fmt.Errorf("open current session store: %w", err)
}
existing, err := targetStore.Load(ctx, adopted.SessionID)
if err != nil {
return fmt.Errorf("load current session state: %w", err)
}
if existing != nil && !opts.Force {
return fmt.Errorf("session %s is already tracked in this repo; rerun with --force to replace it", adopted.SessionID)
}
if err := targetStore.Save(ctx, adopted); err != nil {
return fmt.Errorf("save adopted session state: %w", err)
}
fmt.Fprintf(w, "Adopted session %s from %s\n", shortSessionID(adopted.SessionID), sourceWorktree)
if len(filesTouched) == 0 {
fmt.Fprintln(w, "No current file changes were detected, so the next commit may not link until hooks record changes.")
return nil
}
fmt.Fprintf(w, "Tracking %d file(s): %s\n", len(filesTouched), strings.Join(filesTouched, ", "))
return nil
}
func stateStoreForWorktree(ctx context.Context, worktreePath string) (*session.StateStore, string, error) {
absWorktree, err := filepath.Abs(worktreePath)
if err != nil {
return nil, "", fmt.Errorf("resolve source worktree: %w", err)
}
cmd := exec.CommandContext(ctx, "git", "-C", absWorktree, "rev-parse", "--show-toplevel", "--git-common-dir")
output, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(output))
if msg != "" {
return nil, "", fmt.Errorf("resolve source git directory: %s: %w", msg, err)
}
return nil, "", fmt.Errorf("resolve source git directory: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) < 2 {
return nil, "", fmt.Errorf("resolve source git directory: unexpected git output %q", strings.TrimSpace(string(output)))
}
sourceRoot := strings.TrimSpace(lines[0])
commonDir := strings.TrimSpace(lines[1])
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(absWorktree, commonDir)
}
commonDir = filepath.Clean(commonDir)
return session.NewStateStoreWithDir(filepath.Join(commonDir, session.SessionStateDirName)), sourceRoot, nil
}
func selectAdoptSourceSession(ctx context.Context, store *session.StateStore, sourceWorktree, sessionID string) (*session.State, error) {
if sessionID != "" {
sourceState, err := store.Load(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("load source session state: %w", err)
}
if sourceState == nil {
return nil, fmt.Errorf("session %s was not found in %s", sessionID, sourceWorktree)
}
if sourceState.Phase == session.PhaseEnded || sourceState.FullyCondensed {
return nil, fmt.Errorf("session %s is ended or fully condensed and cannot be adopted", sessionID)
}
return sourceState, nil
}
states, err := store.List(ctx)
if err != nil {
return nil, fmt.Errorf("list source sessions: %w", err)
}
candidates := make([]*session.State, 0, len(states))
for _, state := range states {
if isRecentAdoptCandidate(state) {
candidates = append(candidates, state)
}
}
sort.Slice(candidates, func(i, j int) bool {
return sessionLastSeen(candidates[i]).After(sessionLastSeen(candidates[j]))
})
switch len(candidates) {
case 0:
return nil, fmt.Errorf("no recent active sessions found in %s", sourceWorktree)
case 1:
return candidates[0], nil
default:
ids := make([]string, 0, len(candidates))
for _, candidate := range candidates {
ids = append(ids, candidate.SessionID)
}
return nil, fmt.Errorf("multiple recent active sessions found in %s; pass one of: %s",
sourceWorktree, strings.Join(ids, ", "))
}
}
func isRecentAdoptCandidate(state *session.State) bool {
if state == nil || state.Phase == session.PhaseEnded || state.FullyCondensed {
return false
}
lastSeen := sessionLastSeen(state)
if lastSeen.IsZero() {
return false
}
return time.Since(lastSeen) <= adoptRecentWindow
}
func sessionLastSeen(state *session.State) time.Time {
if state.LastInteractionTime != nil {
return *state.LastInteractionTime
}
return state.StartedAt
}
func buildAdoptedSessionState(ctx context.Context, source *session.State) (*session.State, []string, error) {
repo, err := openRepository(ctx)
if err != nil {
return nil, nil, fmt.Errorf("open current repository: %w", err)
}
defer repo.Close()
head, err := repo.Head()
if err != nil {
return nil, nil, fmt.Errorf("resolve current HEAD: %w", err)
}
worktreeRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return nil, nil, fmt.Errorf("resolve current worktree root: %w", err)
}
worktreeID, err := paths.GetWorktreeID(worktreeRoot)
if err != nil {
return nil, nil, fmt.Errorf("resolve current worktree ID: %w", err)
}
branch, branchErr := GetCurrentBranch(ctx)
if branchErr != nil {
branch = ""
}
filesTouched, err := currentFilesTouched(ctx)
if err != nil {
return nil, nil, err
}
now := time.Now()
adopted := *source
adopted.CLIVersion = versioninfo.Version
adopted.BaseCommit = head.Hash().String()
adopted.AttributionBaseCommit = head.Hash().String()
adopted.WorktreePath = worktreeRoot
adopted.WorktreeID = worktreeID
adopted.Branch = branch
adopted.LastInteractionTime = &now
adopted.FilesTouched = filesTouched
adopted.TurnCheckpointIDs = nil
adopted.LastCheckpointID = id.EmptyCheckpointID
adopted.LastCheckpointCommitHash = ""
adopted.FullyCondensed = false
adopted.DivergenceNoticeShown = false
adopted.UntrackedFilesAtStart = nil
adopted.PromptAttributions = nil
adopted.PendingPromptAttribution = nil
adopted.PromptWindowBase = 0
adopted.PromptWindowResetPending = false
adopted.AttachedManually = true
return &adopted, filesTouched, nil
}
func currentFilesTouched(ctx context.Context) ([]string, error) {
changes, err := DetectFileChanges(ctx, nil)
if err != nil {
return nil, fmt.Errorf("detect current file changes: %w", err)
}
files := mergeUnique(nil, changes.Modified)
files = mergeUnique(files, changes.New)
files = mergeUnique(files, changes.Deleted)
sort.Strings(files)
return files, nil
}
Acmd/entire/cli/session_adopt.go+260
package cli
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/testutil"
)
func TestSessionAdopt_CopiesExternalSessionIntoCurrentWorktree(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetRepo := setupAdoptRepo(t)
sessionID := "test-adopt-session-001"
transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl")
if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(transcriptPath, []byte(`{"type":"user","message":{"role":"user","content":"update target file"},"uuid":"u1"}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName))
lastInteraction := time.Now().Add(-1 * time.Minute)
if err := sourceStore.Save(context.Background(), &session.State{
SessionID: sessionID,
AgentType: agent.AgentTypeClaudeCode,
StartedAt: time.Now().Add(-5 * time.Minute),
LastInteractionTime: &lastInteraction,
Phase: session.PhaseActive,
BaseCommit: testutil.GetHeadHash(t, sourceRepo),
AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo),
WorktreePath: sourceRepo,
TranscriptPath: transcriptPath,
LastPrompt: "update target file",
FilesTouched: []string{"source-only.txt"},
TurnCheckpointIDs: []string{"abc123def456"},
}); err != nil {
t.Fatal(err)
}
testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n")
t.Chdir(targetRepo)
var out bytes.Buffer
err := runAdopt(context.Background(), &out, sessionID, adoptOptions{
FromWorktree: sourceRepo,
Force: true,
})
if err != nil {
t.Fatalf("runAdopt failed: %v", err)
}
targetStore, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatal(err)
}
adopted, err := targetStore.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if adopted == nil {
t.Fatal("expected adopted session state in target repo")
}
if adopted.WorktreePath != targetRepo {
t.Fatalf("WorktreePath = %q, want %q", adopted.WorktreePath, targetRepo)
}
if adopted.BaseCommit != testutil.GetHeadHash(t, targetRepo) {
t.Fatalf("BaseCommit = %q, want target HEAD", adopted.BaseCommit)
}
if adopted.TranscriptPath != transcriptPath {
t.Fatalf("TranscriptPath = %q, want %q", adopted.TranscriptPath, transcriptPath)
}
if !adopted.AttachedManually {
t.Fatal("expected adopted session to be marked manual")
}
if len(adopted.FilesTouched) != 1 || adopted.FilesTouched[0] != "feature.txt" {
t.Fatalf("FilesTouched = %v, want [feature.txt]", adopted.FilesTouched)
}
if len(adopted.TurnCheckpointIDs) != 0 {
t.Fatalf("TurnCheckpointIDs = %v, want empty target-local checkpoint bookkeeping", adopted.TurnCheckpointIDs)
}
if !bytes.Contains(out.Bytes(), []byte("Adopted session")) {
t.Fatalf("output = %q, want adoption confirmation", out.String())
}
}
func TestSessionAdopt_EnablesPrepareCommitMsgTrailer(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetRepo := setupAdoptRepo(t)
sessionID := "test-adopt-trailer-001"
targetRelPath := "src/feature.go"
targetAbsPath := filepath.Join(targetRepo, targetRelPath)
transcriptPath := filepath.Join(sourceRepo, ".claude", sessionID+".jsonl")
if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o750); err != nil {
t.Fatal(err)
}
transcript := `{"type":"human","message":{"content":"write feature.go"}}
{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"` + targetAbsPath + `","content":"package src\n"}}]}}
`
if err := os.WriteFile(transcriptPath, []byte(transcript), 0o600); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-3 * time.Minute)
if err := os.Chtimes(transcriptPath, stale, stale); err != nil {
t.Fatal(err)
}
lastInteraction := time.Now().Add(-1 * time.Minute)
sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName))
if err := sourceStore.Save(context.Background(), &session.State{
SessionID: sessionID,
AgentType: agent.AgentTypeClaudeCode,
StartedAt: time.Now().Add(-5 * time.Minute),
LastInteractionTime: &lastInteraction,
Phase: session.PhaseActive,
BaseCommit: testutil.GetHeadHash(t, sourceRepo),
AttributionBaseCommit: testutil.GetHeadHash(t, sourceRepo),
WorktreePath: sourceRepo,
TranscriptPath: transcriptPath,
LastPrompt: "write feature.go",
}); err != nil {
t.Fatal(err)
}
testutil.WriteFile(t, targetRepo, targetRelPath, "package src\n")
testutil.GitAdd(t, targetRepo, targetRelPath)
t.Chdir(targetRepo)
commitMsgFile := filepath.Join(targetRepo, "COMMIT_EDITMSG")
if err := os.WriteFile(commitMsgFile, []byte("add feature\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := strategy.NewManualCommitStrategy().PrepareCommitMsg(context.Background(), commitMsgFile, ""); err != nil {
t.Fatalf("PrepareCommitMsg failed: %v", err)
}
content, err := os.ReadFile(commitMsgFile)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Entire-Checkpoint:") {
t.Fatalf("commit message = %q, want Entire-Checkpoint trailer", string(content))
}
}
func TestSessionAdopt_FromSubdirectoryReadsSourceStore(t *testing.T) {
sourceRepo := setupAdoptRepo(t)
targetRepo := setupAdoptRepo(t)
sourceSubdir := filepath.Join(sourceRepo, "nested", "dir")
if err := os.MkdirAll(sourceSubdir, 0o750); err != nil {
t.Fatal(err)
}
sessionID := "test-adopt-from-subdir"
lastInteraction := time.Now().Add(-1 * time.Minute)
sourceStore := session.NewStateStoreWithDir(filepath.Join(sourceRepo, ".git", session.SessionStateDirName))
if err := sourceStore.Save(context.Background(), &session.State{
SessionID: sessionID,
AgentType: agent.AgentTypeClaudeCode,
StartedAt: time.Now().Add(-5 * time.Minute),
LastInteractionTime: &lastInteraction,
Phase: session.PhaseActive,
BaseCommit: testutil.GetHeadHash(t, sourceRepo),
WorktreePath: sourceRepo,
}); err != nil {
t.Fatal(err)
}
testutil.WriteFile(t, targetRepo, "feature.txt", "agent change\n")
t.Chdir(targetRepo)
var out bytes.Buffer
err := runAdopt(context.Background(), &out, sessionID, adoptOptions{
FromWorktree: sourceSubdir,
Force: true,
})
if err != nil {
t.Fatalf("runAdopt failed from source subdir: %v", err)
}
}
func setupAdoptRepo(t *testing.T) string {
t.Helper()
repoDir := t.TempDir()
testutil.InitRepo(t, repoDir)
testutil.WriteFile(t, repoDir, "init.txt", "init\n")
testutil.GitAdd(t, repoDir, "init.txt")
testutil.GitCommit(t, repoDir, "init")
enableEntire(t, repoDir)
realRepoDir, err := filepath.EvalSymlinks(repoDir)
if err != nil {
t.Fatal(err)
}
return realRepoDir
}
Acmd/entire/cli/session_adopt_test.go+218