Skip to content
Open
12 changes: 12 additions & 0 deletions cmd/entire/cli/agentimport/agentimport.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
authorName, authorEmail := cp.GetGitAuthorFromRepo(repo)

for sessionIndex, sf := range files {
// Stop before reading and splitting the next transcript.
if err := ctx.Err(); err != nil {
return res, err //nolint:wrapcheck // propagate context cancellation
}
res.SessionsScanned++
full, readErr := os.ReadFile(sf.Path)
if readErr != nil {
Expand All @@ -233,6 +237,14 @@ func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options)
var red redact.RedactedBytes
redacted := false
for turnIndex, turn := range turns {
// Ctrl-C must stop the import, and per turn rather than per session:
// one session can carry hundreds of turns, each a checkpoint write.
// The store guards its create path too, but only this stops the
// per-turn work leading up to it (reading, splitting, redacting),
// and it is the only brake at all under DryRun, which never writes.
if err := ctx.Err(); err != nil {
return res, err //nolint:wrapcheck // propagate context cancellation
}
cid := DeriveCheckpointID(sf.SessionID, turn.UUID)
if existing[cid.String()] {
res.TurnsSkipped++
Expand Down
86 changes: 86 additions & 0 deletions cmd/entire/cli/agentimport/agentimport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agentimport

import (
"context"
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -619,3 +620,88 @@ func TestRun_CodexImportSanitizesAndKeepsOffsetsAligned(t *testing.T) {
"(CheckpointTranscriptStart from raw line indices) would drift", got, rawLines)
}
}

// TestRun_StopsOnContextCancellation proves Ctrl-C mid-import halts Run's own
// loops, independently of whether the configured checkpoint store rejects a
// canceled write. DryRun writes nothing, so the loop checks are the only thing
// that can stop this run.
func TestRun_StopsOnContextCancellation(t *testing.T) {
t.Parallel()
repo, repoDir := initRepoWithCommit(t)
claudeDir := t.TempDir()
writeFixtureSession(t, claudeDir, "sess1.jsonl")
writeFixtureSession(t, claudeDir, "sess2.jsonl")

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Cancel as soon as the first turn is processed, standing in for a Ctrl-C
// during the import. DryRun reports every turn through TurnSkipped.
opts := Options{
RepoRoot: repoDir,
OverridePath: claudeDir,
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
DryRun: true,
Progress: &Progress{TurnSkipped: func(int, int, int) { cancel() }},
}

res, err := Run(ctx, repo, claudeImporter{}, opts)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context.Canceled", err)
}
// 4 turns across 2 sessions; the cancel lands after the first.
if res.TurnsImported != 1 {
t.Fatalf("run continued past cancellation: processed %d turns, want 1 (%+v)",
res.TurnsImported, res)
}
if res.SessionsScanned != 1 {
t.Fatalf("run walked into session %d after cancellation, want to stop at 1",
res.SessionsScanned)
}
}

// TestRun_CancellationStopsRefsBackedImport is the end-to-end regression for
// the reported bug, in the configuration it was reported on: `entire enable`
// defaults a first-time repo to the git-refs checkpoint backend and then
// offers to import agent history. See gitRefsStore.writeSession for why no
// layer below this one stopped the run.
func TestRun_CancellationStopsRefsBackedImport(t *testing.T) {
// Not parallel: sets the checkpoint backend via the environment.
t.Setenv("ENTIRE_CHECKPOINTS_PRIMARY", "git-refs")

repo, repoDir := initRepoWithCommit(t)
claudeDir := t.TempDir()
writeFixtureSession(t, claudeDir, "sess1.jsonl")
writeFixtureSession(t, claudeDir, "sess2.jsonl")

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

opts := Options{
RepoRoot: repoDir,
OverridePath: claudeDir,
Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),
Progress: &Progress{TurnWritten: func(int, int, int) { cancel() }},
}

res, err := Run(ctx, repo, claudeImporter{}, opts)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context.Canceled", err)
}
if res.TurnsImported != 1 {
t.Fatalf("import continued past cancellation: TurnsImported = %d, want 1 (%+v)",
res.TurnsImported, res)
}

stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})
if err != nil {
t.Fatal(err)
}
infos, err := stores.Persistent.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(infos) != 1 {
t.Fatalf("wrote %d checkpoints after cancellation, want 1 (the in-flight turn)", len(infos))
}
}
34 changes: 33 additions & 1 deletion cmd/entire/cli/checkpoint/git_common_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,35 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"

"github.com/go-git/go-git/v6"
)

// commonDirCache memoizes resolved git common dirs by worktree root. The value
// cannot change for a worktree's lifetime, and resolving it costs a git
// subprocess (~10ms) — which the push-discovery queue pays on EVERY checkpoint
// ref write and the ephemeral store pays on every shadow-branch write, i.e.
// once per agent turn on the hook path and once per turn imported by `entire
// import`. Mirrors session.getGitCommonDir, which already caches this same
// value process-wide for its own callers.
//
// Only successes are cached: a failure may be transient (a canceled context
// fails the subprocess instantly), and caching it would poison every later
// caller in the process.
var (
commonDirMu sync.RWMutex
commonDirCache = map[string]string{} // worktree root -> resolved common dir
)

// cachedCommonDir returns the memoized common dir for a worktree root.
func cachedCommonDir(root string) (string, bool) {
commonDirMu.RLock()
defer commonDirMu.RUnlock()
dir, ok := commonDirCache[root]
return dir, ok
}

func resolveGitCommonDir(ctx context.Context, repo *git.Repository) (string, error) {
worktree, err := repo.Worktree()
if err != nil {
Expand All @@ -20,6 +45,9 @@ func resolveGitCommonDir(ctx context.Context, repo *git.Repository) (string, err
if root == "" {
return "", errors.New("resolve worktree root for git common dir")
}
if cached, ok := cachedCommonDir(root); ok {
return cached, nil
}

cmd := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--git-common-dir")
// Use Output (not CombinedOutput) so stderr never pollutes the resolved
Expand All @@ -42,5 +70,9 @@ func resolveGitCommonDir(ctx context.Context, repo *git.Repository) (string, err
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(root, commonDir)
}
return filepath.Clean(commonDir), nil
commonDir = filepath.Clean(commonDir)
commonDirMu.Lock()
commonDirCache[root] = commonDir
commonDirMu.Unlock()
return commonDir, nil
}
86 changes: 86 additions & 0 deletions cmd/entire/cli/checkpoint/git_common_dir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package checkpoint

import (
"context"
"testing"

git "github.com/go-git/go-git/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/entireio/cli/cmd/entire/cli/testutil"
)

// openTestRepo initializes a repo with one commit and opens it.
func openTestRepo(t *testing.T) *git.Repository {
t.Helper()
dir := t.TempDir()
testutil.InitRepo(t, dir)
testutil.WriteFile(t, dir, "README.md", "# test")
testutil.GitAdd(t, dir, "README.md")
testutil.GitCommit(t, dir, "init")
repo, err := git.PlainOpen(dir)
require.NoError(t, err)
return repo
}

// TestResolveGitCommonDir_CachesPerWorktree proves the memo is keyed by
// worktree root rather than shared globally: two repos must never resolve to
// each other's common dir. A single cached value (or a cwd-keyed one) would
// hand the second repo the first's .git, silently writing one repo's
// push-discovery queue into another's.
func TestResolveGitCommonDir_CachesPerWorktree(t *testing.T) {
t.Parallel()
ctx := context.Background()

repoA, repoB := openTestRepo(t), openTestRepo(t)

firstA, err := resolveGitCommonDir(ctx, repoA)
require.NoError(t, err)
firstB, err := resolveGitCommonDir(ctx, repoB)
require.NoError(t, err)
assert.NotEqual(t, firstA, firstB, "distinct repos must resolve to distinct common dirs")

// Second resolution is served from the memo and must agree with the first.
secondA, err := resolveGitCommonDir(ctx, repoA)
require.NoError(t, err)
assert.Equal(t, firstA, secondA)
secondB, err := resolveGitCommonDir(ctx, repoB)
require.NoError(t, err)
assert.Equal(t, firstB, secondB)
}

// TestResolveGitCommonDir_CachedValueServesCanceledContext proves a cache hit
// needs no subprocess: once resolved, a canceled context still yields the
// common dir. This is what lets enqueueForPush record a ref during shutdown
// without paying (or failing) a `git rev-parse`.
func TestResolveGitCommonDir_CachedValueServesCanceledContext(t *testing.T) {
t.Parallel()
repo := openTestRepo(t)

want, err := resolveGitCommonDir(context.Background(), repo)
require.NoError(t, err)

canceled, cancel := context.WithCancel(context.Background())
cancel()
got, err := resolveGitCommonDir(canceled, repo)
require.NoError(t, err, "a cached common dir must not need the subprocess")
assert.Equal(t, want, got)
}

// TestResolveGitCommonDir_FailureIsNotCached proves a failed resolution leaves
// the memo empty, so a transient failure (e.g. a canceled context) cannot
// poison every later caller in the process.
func TestResolveGitCommonDir_FailureIsNotCached(t *testing.T) {
t.Parallel()
repo := openTestRepo(t)

canceled, cancel := context.WithCancel(context.Background())
cancel()
_, err := resolveGitCommonDir(canceled, repo)
require.Error(t, err, "a canceled context should fail the resolving subprocess")

got, err := resolveGitCommonDir(context.Background(), repo)
require.NoError(t, err, "the earlier failure must not be cached")
assert.NotEmpty(t, got)
}
9 changes: 9 additions & 0 deletions cmd/entire/cli/checkpoint/persistent.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ var chunkTranscript = agent.ChunkTranscript
// - For incremental checkpoints: checkpoints/NNN-<tool-use-id>.json
// - For final checkpoints: checkpoint.json and agent-<agent-id>.jsonl
func (s *GitStore) writeSession(ctx context.Context, opts WriteOptions) error {
// Parity with this store's backfill writers and with the git-refs store: a
// canceled ctx means stop doing work, and creating a checkpoint is the most
// expensive write there is. Nothing below here observes cancellation
// (ensureSessionsBranch, tree building and CreateCommit all ignore ctx), so
// without this a bulk writer that ignores cancellation keeps minting
// checkpoints after Ctrl-C.
if err := ctx.Err(); err != nil {
return err //nolint:wrapcheck // Propagating context cancellation
}
// Validate identifiers to prevent path traversal and malformed data
if opts.CheckpointID.IsEmpty() {
return errors.New("invalid checkpoint options: checkpoint ID is required")
Expand Down
35 changes: 35 additions & 0 deletions cmd/entire/cli/checkpoint/persistent_imported_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"

"github.com/go-git/go-git/v6"
Expand Down Expand Up @@ -198,3 +199,37 @@ func TestImportedCheckpoint_CommitSHASurvivesSummaryRewrite(t *testing.T) {
t.Fatalf("expected both sessions in the rewritten summary, got %d", len(summary.Sessions))
}
}

// TestGitStore_WriteRefusesCanceledContext pins that the git-branch store, like
// the git-refs store and like its own backfill writers, stops creating
// checkpoints once the context is canceled. It is still the runtime fallback
// when no checkpoints config is present and remains selectable via
// `--checkpoint-backend branch`, so a guard applied only to git-refs would
// leave every branch-backed repo without a store-level brake on Ctrl-C.
func TestGitStore_WriteRefusesCanceledContext(t *testing.T) {
t.Parallel()
store, transcript := newImportedTestStore(t)

ctx, cancel := context.WithCancel(context.Background())
cancel()

err := store.Write(ctx, Session{
CheckpointID: id.MustCheckpointID("a1b2c3d4e5f6"),
SessionID: "sess-1",
Strategy: "manual-commit",
Transcript: transcript,
AuthorName: "Test Author",
AuthorEmail: "test@example.com",
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("Write() with a canceled context = %v, want context.Canceled", err)
}

infos, listErr := store.List(context.Background())
if listErr != nil {
t.Fatalf("List: %v", listErr)
}
if len(infos) != 0 {
t.Fatalf("a write refused for cancellation left %d checkpoint(s) behind", len(infos))
}
}
21 changes: 20 additions & 1 deletion cmd/entire/cli/checkpoint/refs_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,19 @@ func (s *gitRefsStore) setRef(ctx context.Context, cid id.CheckpointID, hash plu

// enqueueForPush records refName in the push-discovery queue, logging (never
// returning) on failure so the local ref write still succeeds.
//
// The queue is resolved with the cancellation stripped from ctx. By the time we
// get here the ref is already written locally (go-git ref writes don't observe
// ctx), and the queue is the ONLY push-discovery mechanism there is — see the
// pushQueueFileName doc. A ref that misses the queue is never pushed, and the
// writers above are idempotent, so a re-run skips it as already-present and
// never re-enqueues it: it stays local-only forever. Resolving the queue shells
// out to `git rev-parse --git-common-dir`, which fails instantly on a canceled
// ctx, so honoring cancellation here would drop the bookkeeping for a write
// that already happened. This local, sub-millisecond step therefore completes
// even during shutdown (a second Ctrl-C still force-quits the process).
func (s *gitRefsStore) enqueueForPush(ctx context.Context, refName plumbing.ReferenceName) {
q, err := PushQueueForRepo(ctx, s.repo)
q, err := PushQueueForRepo(context.WithoutCancel(ctx), s.repo)
if err != nil {
logging.Warn(ctx, "checkpoint: resolve push queue failed; ref not enqueued",
slog.String("ref", refName.String()), slog.String("error", err.Error()))
Expand All @@ -226,6 +237,14 @@ func (s *gitRefsStore) enqueueForPush(ctx context.Context, refName plumbing.Refe
}

func (s *gitRefsStore) writeSession(ctx context.Context, opts WriteOptions) error {
// Parity with the backfill writers above and with GitStore.writeSession: a
// canceled ctx means stop doing work, and creating a checkpoint is the most
// expensive write there is (tree building plus a commit). Without this a
// bulk writer that ignores cancellation — `entire import` was one — keeps
// minting checkpoints after Ctrl-C.
if err := ctx.Err(); err != nil {
return err //nolint:wrapcheck // Propagating context cancellation
}
if opts.CheckpointID.IsEmpty() {
return errors.New("invalid checkpoint options: checkpoint ID is required")
}
Expand Down
Loading
Loading