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
272 changes: 272 additions & 0 deletions cmd/entire/cli/session_adopt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
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, ", "))
fmt.Fprintln(w, "Review tracked files before committing; adoption attributes current changes in this repo to the adopted session.")
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Git rev-parse uses CombinedOutput

Low Severity

stateStoreForWorktree resolves --show-toplevel and --git-common-dir with cmd.CombinedOutput() and then splits stdout into paths. Git may write advice or warnings to stderr on success; merging streams can prepend that text to the parsed lines and break common-dir resolution for the source session store.

Fix in Cursor Fix in Web

Triggered by learned rule: Use cmd.Output() with separate stderr buffer for git CLI commands that parse stdout

Reviewed by Cursor Bugbot for commit 6add2ba. Configure here.

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)
}
Comment on lines +143 to +145
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
}
Comment on lines +178 to +181
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

// Keep the source live transcript path. In cross-repo adoption the transcript
// belongs to the continuing agent session, not the target repository; clearing
// or recomputing it from the target repo would drop live transcript capture.
adopted.CLIVersion = versioninfo.Version
adopted.TranscriptPath = source.TranscriptPath
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

// Reset target-local checkpoint bookkeeping. Source checkpoint IDs can point
// at metadata in another repository or checkpoint branch; carrying them into
// this repo would let amend and turn-finalization paths operate on unrelated
// checkpoints.
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 = false

return &adopted, filesTouched, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale transcript offsets block trailers

High Severity

buildAdoptedSessionState clears target-local checkpoint IDs but keeps source CheckpointTranscriptStart and CheckpointTranscriptSize. After adoption, prepare-commit-msg often uses the live-transcript path (no shadow branch on the target repo). That path calls hasNewTranscriptWork before considering seeded FilesTouched, so when the transcript position has not moved past the copied offset, the hook treats the session as having no new content and skips the Entire-Checkpoint trailer despite staged overlap.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6add2ba. Configure here.

}

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
}
Loading
Loading