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
71 changes: 67 additions & 4 deletions cmd/entire/cli/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/entireio/cli/cmd/entire/cli/checkpoint/remote"
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/trailers"
Expand All @@ -39,6 +40,10 @@ import (
// agent_review in the checkpoint metadata.
type attachOptions struct {
Force bool
// AllowCrossWorktree permits attaching a session whose stored worktree path
// differs from the current checkout. Force deliberately does not imply this:
// skipping the amend prompt should not also bypass session identity checks.
AllowCrossWorktree bool
// Review, when true, tags the attached session as a review. Skills are
// resolved inside runAttach after the real agent is known (via session
// state or transcript auto-detection), not at the cobra layer — the
Expand Down Expand Up @@ -72,10 +77,11 @@ func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.Persis

func newAttachCmd() *cobra.Command {
var (
force bool
agentFlag string
reviewFlag bool
skillsFlag []string
force bool
allowCrossWorktree bool
agentFlag string
reviewFlag bool
skillsFlag []string
)
cmd := &cobra.Command{
Use: "attach <session-id>",
Expand Down Expand Up @@ -109,13 +115,15 @@ external_agents in settings. Run 'entire agent list' to see the full list.`,
agentName := types.AgentName(agentFlag)
opts := attachOptions{
Force: force,
AllowCrossWorktree: allowCrossWorktree,
Review: reviewFlag,
ReviewSkillsOverride: skillsFlag,
}
return runAttachSurfaceReviewErrors(cmd, args[0], agentName, opts)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation and amend the last commit with the checkpoint trailer")
cmd.Flags().BoolVar(&allowCrossWorktree, "allow-cross-worktree", false, "Attach even if the session was recorded in a different worktree")
cmd.Flags().StringVarP(&agentFlag, "agent", "a", string(agent.DefaultAgentName), "Agent that created the session (see 'entire agent list' for registered agents, including external)")
cmd.Flags().BoolVar(&reviewFlag, "review", false, "Tag the attached session as an agent review")
cmd.Flags().StringSliceVar(&skillsFlag, "skills", nil, "Optional: declare which review skills were run in this session. Only used with --review")
Expand Down Expand Up @@ -200,6 +208,9 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ
if err != nil {
return err
}
if err := validateAttachWorktree(ctx, existingState, opts); err != nil {
return err
}

headCommit, err := getHeadCommit(repo)
if err != nil {
Expand Down Expand Up @@ -362,6 +373,58 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ
return nil
}

func validateAttachWorktree(ctx context.Context, existingState *session.State, opts attachOptions) error {
if existingState == nil || opts.AllowCrossWorktree {
return nil
}

currentWorktree, err := paths.WorktreeRoot(ctx)
if err != nil {
return fmt.Errorf("failed to resolve current worktree for session %s: %w", existingState.SessionID, err)
}

if existingState.WorktreeID != "" {
currentWorktreeID, idErr := paths.GetWorktreeID(currentWorktree)
if idErr != nil {
return fmt.Errorf("failed to resolve current worktree ID for session %s: %w", existingState.SessionID, idErr)
}
if existingState.WorktreeID == currentWorktreeID {
return nil
}
return attachWorktreeMismatchError(existingState, currentWorktree, currentWorktreeID)
}

if existingState.WorktreePath == "" {
return nil
}

if normalizeWorktreePath(existingState.WorktreePath) == normalizeWorktreePath(currentWorktree) {
return nil
}

return attachWorktreeMismatchError(existingState, currentWorktree, "")
}

func attachWorktreeMismatchError(existingState *session.State, currentWorktree string, currentWorktreeID string) error {
if existingState.WorktreeID != "" || currentWorktreeID != "" {
return fmt.Errorf(
"session %s was recorded in a different worktree; session worktree: %s; current worktree: %s; session worktree ID: %s; current worktree ID: %s. Run attach from the session worktree or pass --allow-cross-worktree to override",
existingState.SessionID,
existingState.WorktreePath,
currentWorktree,
existingState.WorktreeID,
currentWorktreeID,
)
}

return fmt.Errorf(
"session %s was recorded in a different worktree; session worktree: %s; current worktree: %s. Run attach from the session worktree or pass --allow-cross-worktree to override",
existingState.SessionID,
existingState.WorktreePath,
currentWorktree,
)
Comment thread
peyton-alt marked this conversation as resolved.
}

// checkpointHasSessionMetadata reports whether sessionID has existing metadata
// at Primary. Reads target Primary directly, not refs.Read, because this guard
// must reflect what the next write would target.
Expand Down
120 changes: 120 additions & 0 deletions cmd/entire/cli/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,126 @@ func TestAttach_SessionAlreadyTracked_NoCheckpoint(t *testing.T) {
}
}

func TestAttach_RefusesExistingSessionFromDifferentWorktree(t *testing.T) {
setupAttachTestRepo(t)

repoRoot := mustGetwd(t)
sessionID := "test-attach-wrong-worktree"
setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"work from another checkout"},"uuid":"uuid-1"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]},"uuid":"uuid-2"}
`)

wrongWorktree := filepath.Join(filepath.Dir(repoRoot), "other-worktree")
if err := os.MkdirAll(wrongWorktree, 0o755); err != nil {
t.Fatal(err)
}

store, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatal(err)
}
if err := store.Save(context.Background(), &session.State{
SessionID: sessionID,
AgentType: agent.AgentTypeClaudeCode,
StartedAt: time.Now(),
WorktreePath: wrongWorktree,
}); err != nil {
t.Fatal(err)
}

var out bytes.Buffer
err = runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, attachOptions{Force: true})
if err == nil {
t.Fatal("expected attach to refuse a session recorded for another worktree")
}
if !strings.Contains(err.Error(), "different worktree") {
t.Fatalf("error should explain the worktree mismatch, got: %v", err)
}

reloadedState, err := store.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if reloadedState == nil {
t.Fatal("expected session state to remain")
}
if !reloadedState.LastCheckpointID.IsEmpty() {
t.Errorf("attach should not write a checkpoint for the wrong-worktree session, got %s", reloadedState.LastCheckpointID)
}

repo, err := git.PlainOpen(repoRoot)
if err != nil {
t.Fatal(err)
}
headCommit, err := getHeadCommit(repo)
if err != nil {
t.Fatal(err)
}
if checkpoints := trailers.ParseAllCheckpoints(headCommit.Message); len(checkpoints) != 0 {
t.Fatalf("attach should not amend HEAD on refusal; found checkpoints %v", checkpoints)
}
}

func TestAttach_AllowCrossWorktreeOverridesGuard(t *testing.T) {
setupAttachTestRepo(t)

repoRoot := mustGetwd(t)
sessionID := "test-attach-cross-worktree-override"
setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"repair from here"},"uuid":"uuid-1"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]},"uuid":"uuid-2"}
`)

otherWorktree := filepath.Join(filepath.Dir(repoRoot), "other-worktree")
if err := os.MkdirAll(otherWorktree, 0o755); err != nil {
t.Fatal(err)
}

store, err := session.NewStateStore(context.Background())
if err != nil {
t.Fatal(err)
}
if err := store.Save(context.Background(), &session.State{
SessionID: sessionID,
AgentType: agent.AgentTypeClaudeCode,
StartedAt: time.Now(),
WorktreePath: otherWorktree,
}); err != nil {
t.Fatal(err)
}

var out bytes.Buffer
err = runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, attachOptions{
Force: true,
AllowCrossWorktree: true,
})
if err != nil {
t.Fatalf("expected explicit cross-worktree override to attach, got error: %v", err)
}

reloadedState, err := store.Load(context.Background(), sessionID)
if err != nil {
t.Fatal(err)
}
if reloadedState == nil {
t.Fatal("expected session state to remain")
}
if reloadedState.LastCheckpointID.IsEmpty() {
t.Fatal("expected LastCheckpointID to be set after override attach")
}

repo, err := git.PlainOpen(repoRoot)
if err != nil {
t.Fatal(err)
}
headCommit, err := getHeadCommit(repo)
if err != nil {
t.Fatal(err)
}
if checkpoints := trailers.ParseAllCheckpoints(headCommit.Message); len(checkpoints) != 1 {
t.Fatalf("expected override attach to amend HEAD with one checkpoint, got %v", checkpoints)
}
}

func TestAttach_OutputContainsCheckpointID(t *testing.T) {
setupAttachTestRepo(t)

Expand Down
Loading
Loading