Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
68 changes: 68 additions & 0 deletions cmd/entire/cli/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ
// Init failed — logging will use stderr fallback, non-fatal.
_ = err
}
defer logging.Close()

logCtx := logging.WithComponent(ctx, "attach")

Expand Down Expand Up @@ -135,6 +136,14 @@ func runAttach(ctx context.Context, w io.Writer, sessionID string, agentName typ
// Determine checkpoint ID: reuse from HEAD if one exists, otherwise generate new.
checkpointID, isExistingCheckpoint := resolveCheckpointID(headCommit)

// If HEAD references an existing checkpoint, make sure we have it locally
// before writing — otherwise we'd create a fresh session 0 under the same
// ID and overwrite the original on push.
repo, err = ensureCheckpointAvailable(ctx, logCtx, repo, checkpointID, isExistingCheckpoint)
if err != nil {
return err
}

// Write directly to entire/checkpoints/v1.
store := cpkg.NewGitStore(repo)

Expand Down Expand Up @@ -244,6 +253,65 @@ func getHeadCommit(repo *git.Repository) (*object.Commit, error) {
return commit, nil
}

// ensureCheckpointAvailable makes sure the checkpoint referenced by HEAD is
// present locally before the attach writes to it. Without this guard, attach
// would create a fresh session 0 under the same ID and overwrite the original
// session data on push.
//
// Checks the local entire/checkpoints/v1 branch first — if the checkpoint is
// already there, no network is needed. Otherwise triggers the metadata fetch
// fallback chain used by `entire resume` and re-checks. Returns a
// possibly-freshly-opened repo handle so go-git sees any newly fetched
// packfiles.
func ensureCheckpointAvailable(ctx, logCtx context.Context, repo *git.Repository, checkpointID id.CheckpointID, isExistingCheckpoint bool) (*git.Repository, error) {
if !isExistingCheckpoint {
return repo, nil
}

// Fast path: already local, skip the network round-trip.
store := cpkg.NewGitStore(repo)
summary, readErr := store.ReadCommitted(ctx, checkpointID)
Comment thread
Soph marked this conversation as resolved.
Outdated
if readErr != nil {
return repo, fmt.Errorf("failed to read checkpoint %s: %w", checkpointID, readErr)
}
if summary != nil {
return repo, nil
}
Comment thread
Soph marked this conversation as resolved.
Outdated

// Missing locally — try to refresh, then re-check.
if _, freshRepo, fetchErr := getMetadataTree(ctx); fetchErr != nil {
logging.Warn(logCtx, "failed to refresh metadata branch before attach; proceeding with local state",
slog.String("error", fetchErr.Error()))
} else {
repo = freshRepo
store = cpkg.NewGitStore(repo)
summary, readErr = store.ReadCommitted(ctx, checkpointID)
if readErr != nil {
return repo, fmt.Errorf("failed to read checkpoint %s after refresh: %w", checkpointID, readErr)
}
if summary != nil {
return repo, nil
}
}

return repo, fmt.Errorf(
"checkpoint %s referenced by HEAD is missing from the local entire/checkpoints/v1 branch after a refresh attempt. Creating a fresh checkpoint here would overwrite the original session data on push. Run:\n\n %s\n\nthen re-run attach. If the colleague who made this commit hasn't pushed their checkpoint metadata yet, ask them to do so first",
checkpointID.String(), suggestCheckpointFetchCommand(logCtx),
)
}

// suggestCheckpointFetchCommand returns a git fetch command string the user
// can paste, aware of whether checkpoint_remote is configured.
func suggestCheckpointFetchCommand(ctx context.Context) string {
const ref = "entire/checkpoints/v1:entire/checkpoints/v1"
Comment thread
Soph marked this conversation as resolved.
Outdated
if remote.Configured(ctx) {
if url, err := remote.FetchURL(ctx); err == nil && url != "" {
return fmt.Sprintf("git fetch %s %s", url, ref)
}
}
return "git fetch origin " + ref
}

// resolveCheckpointID returns the checkpoint ID to use for the attach.
// If HEAD already has an Entire-Checkpoint trailer, reuses that ID (the session
// gets added as an additional session in the existing checkpoint).
Expand Down
117 changes: 117 additions & 0 deletions cmd/entire/cli/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import (
_ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" // register agent
_ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" // register agent
"github.com/entireio/cli/cmd/entire/cli/agent/types"
cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/session"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/cmd/entire/cli/trailers"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
Expand Down Expand Up @@ -255,6 +257,112 @@ func TestAttach_V2DualWriteDisabled(t *testing.T) {
}
}

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

firstSessionID := "first-session-a-original"
setupClaudeTranscript(t, firstSessionID, `{"type":"user","message":{"role":"user","content":"first"},"uuid":"u1"}
`)
var out bytes.Buffer
if err := runAttach(context.Background(), &out, firstSessionID, agent.AgentNameClaudeCode, true); err != nil {
t.Fatalf("first attach failed: %v", err)
}

repoRoot := mustGetwd(t)
repo, err := git.PlainOpen(repoRoot)
if err != nil {
t.Fatal(err)
}
headRef, err := repo.Head()
if err != nil {
t.Fatal(err)
}
headCommit, err := repo.CommitObject(headRef.Hash())
if err != nil {
t.Fatal(err)
}
existingCheckpoints := trailers.ParseAllCheckpoints(headCommit.Message)
if len(existingCheckpoints) != 1 {
t.Fatalf("expected one Entire-Checkpoint trailer after first attach; got %v", existingCheckpoints)
}
checkpointID := existingCheckpoints[0]

secondSessionID := "second-session-b-append"
setupClaudeTranscript(t, secondSessionID, `{"type":"user","message":{"role":"user","content":"second"},"uuid":"u1"}
`)
out.Reset()
if err := runAttach(context.Background(), &out, secondSessionID, agent.AgentNameClaudeCode, true); err != nil {
t.Fatalf("second attach failed: %v", err)
}

store := cpkg.NewGitStore(repo)
summary, err := store.ReadCommitted(context.Background(), checkpointID)
if err != nil {
t.Fatalf("ReadCommitted(%s): %v", checkpointID, err)
}
if summary == nil {
t.Fatalf("checkpoint %s summary nil after two attaches", checkpointID)
}
if len(summary.Sessions) != 2 {
t.Fatalf("checkpoint has %d sessions, want 2", len(summary.Sessions))
}

idx0, err := store.ReadSessionContent(context.Background(), checkpointID, 0)
if err != nil {
t.Fatalf("ReadSessionContent(0): %v", err)
}
idx1, err := store.ReadSessionContent(context.Background(), checkpointID, 1)
if err != nil {
t.Fatalf("ReadSessionContent(1): %v", err)
}
haveFirst := idx0.Metadata.SessionID == firstSessionID || idx1.Metadata.SessionID == firstSessionID
haveSecond := idx0.Metadata.SessionID == secondSessionID || idx1.Metadata.SessionID == secondSessionID
if !haveFirst {
t.Errorf("first session %q missing from checkpoint; got [%q, %q]",
firstSessionID, idx0.Metadata.SessionID, idx1.Metadata.SessionID)
}
if !haveSecond {
t.Errorf("second session %q missing from checkpoint; got [%q, %q]",
secondSessionID, idx0.Metadata.SessionID, idx1.Metadata.SessionID)
}
}

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

repoRoot := mustGetwd(t)
runGitInDir(t, repoRoot, "commit", "--amend", "-m", "init\n\nEntire-Checkpoint: ffffffffeeee")

sessionID := "orphaned-attach-session"
setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"attach please"},"uuid":"u1"}
`)

var out bytes.Buffer
err := runAttach(context.Background(), &out, sessionID, agent.AgentNameClaudeCode, true)
if err == nil {
t.Fatal("expected error: checkpoint referenced by HEAD is missing locally and attach should refuse")
}
if !strings.Contains(err.Error(), "missing from the local entire/checkpoints/v1 branch") {
t.Errorf("error message should explain the missing-branch situation; got: %v", err)
}
if !strings.Contains(err.Error(), "git fetch origin entire/checkpoints/v1") {
t.Errorf("error message should include the fetch command to fix it; got: %v", err)
}

repo, err := git.PlainOpen(repoRoot)
if err != nil {
t.Fatal(err)
}
store := cpkg.NewGitStore(repo)
summary, err := store.ReadCommitted(context.Background(), "ffffffffeeee")
if err != nil {
t.Fatalf("ReadCommitted: %v", err)
}
if summary != nil {
t.Errorf("attach should NOT have created checkpoint ffffffffeeee locally; found %+v", summary)
}
}

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

Expand Down Expand Up @@ -847,3 +955,12 @@ func TestAttach_DiscoversExternalAgents(t *testing.T) {
t.Errorf("expected external agent %q in registry after attach, got: %v", agentName, lookupErr)
}
}

func runGitInDir(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.CommandContext(context.Background(), "git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
}
}
25 changes: 25 additions & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,20 @@ func (s *GitStore) writeStandardCheckpointEntries(ctx context.Context, opts Writ
// Determine session index: reuse existing slot if session ID matches, otherwise append
sessionIndex := s.findSessionIndex(ctx, basePath, existingSummary, entries, opts.SessionID)

// Capture any pre-existing session-0 metadata before writeSessionToSubdirectory
// clears that subtree. The warning below only fires in the suspicious shape
// where findSessionIndex chose slot 0 but the tree already had session-0
// metadata for a different session — typically meaning the root summary is
// missing/stale while a numbered session subdir still exists.
var existingSessionZeroMeta *CommittedMetadata
if sessionIndex == 0 {
if entry, exists := entries[fmt.Sprintf("%s0/%s", basePath, paths.MetadataFileName)]; exists {
if existingMeta, readErr := s.readMetadataFromBlob(entry.Hash); readErr == nil {
existingSessionZeroMeta = existingMeta
}
}
}

// Write session files to numbered subdirectory
sessionPath := fmt.Sprintf("%s%d/", basePath, sessionIndex)
sessionFilePaths, err := s.writeSessionToSubdirectory(ctx, opts, sessionPath, entries)
Expand All @@ -341,6 +355,17 @@ func (s *GitStore) writeStandardCheckpointEntries(ctx context.Context, opts Writ
}
sessions[sessionIndex] = sessionFilePaths

// Tripwire: if we're writing session 0 and there was already session-0
// metadata for a DIFFERENT session ID, emit a loud warning. This is a
// tree-corruption / stale-summary shape, not a routine overwrite path.
if existingSessionZeroMeta != nil && existingSessionZeroMeta.SessionID != opts.SessionID {
logging.Warn(ctx, "checkpoint write overwrites session 0 with a different sessionID — potential overwrite regression",
slog.String("checkpoint_id", opts.CheckpointID.String()),
slog.String("existing_session_id", existingSessionZeroMeta.SessionID),
slog.String("write_session_id", opts.SessionID),
slog.Bool("existing_summary_nil", existingSummary == nil))
}
Comment thread
Soph marked this conversation as resolved.
Outdated

// Update root metadata.json with CheckpointSummary
return s.writeCheckpointSummary(opts, basePath, entries, sessions)
}
Expand Down
90 changes: 90 additions & 0 deletions cmd/entire/cli/checkpoint/committed_tripwire_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package checkpoint

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

"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/versioninfo"
"github.com/entireio/cli/redact"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
)

func TestWriteStandardCheckpointEntries_WarnsOnUnexpectedSessionZeroOverwrite(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)

repo, err := git.PlainInit(tmpDir, false)
if err != nil {
t.Fatalf("PlainInit() error = %v", err)
}
store := NewGitStore(repo)

if err := logging.Init(context.Background(), ""); err != nil {
t.Fatalf("logging.Init() error = %v", err)
}
Comment thread
Soph marked this conversation as resolved.

checkpointID, err := id.Generate()
if err != nil {
t.Fatalf("id.Generate() error = %v", err)
}
basePath := checkpointID.Path() + "/"

oldMetadata := CommittedMetadata{
CheckpointID: checkpointID,
SessionID: "session-old",
Strategy: "manual-commit",
CLIVersion: versioninfo.Version,
}
oldMetadataJSON, err := jsonutil.MarshalIndentWithNewline(oldMetadata, "", " ")
if err != nil {
t.Fatalf("marshal old metadata: %v", err)
}
oldMetadataHash, err := CreateBlobFromContent(repo, oldMetadataJSON)
if err != nil {
t.Fatalf("CreateBlobFromContent(old metadata) error = %v", err)
}

entries := map[string]object.TreeEntry{
basePath + "0/" + paths.MetadataFileName: {
Name: basePath + "0/" + paths.MetadataFileName,
Mode: filemode.Regular,
Hash: oldMetadataHash,
},
}

opts := WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: "session-new",
Strategy: "manual-commit",
Transcript: redact.AlreadyRedacted([]byte("{\"type\":\"user\",\"message\":\"hi\"}\n")),
Prompts: []string{"hi"},
}

if err := store.writeStandardCheckpointEntries(context.Background(), opts, basePath, entries); err != nil {
t.Fatalf("writeStandardCheckpointEntries() error = %v", err)
}
logging.Close()

logPath := filepath.Join(tmpDir, logging.LogsDir, "entire.log")
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", logPath, err)
}
logText := string(logData)
if !strings.Contains(logText, "checkpoint write overwrites session 0 with a different sessionID") {
t.Fatalf("expected tripwire warning in log, got:\n%s", logText)
}
if !strings.Contains(logText, "session-old") || !strings.Contains(logText, "session-new") {
t.Fatalf("expected log to include both session IDs, got:\n%s", logText)
}
}
Loading