From 1dae8ae2d19091f1848fbe2ccdcc1437a3727f37 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 18 Feb 2026 22:45:37 -0800 Subject: [PATCH 1/5] add first benchmark for writetemporary and write committed --- cmd/entire/cli/checkpoint/bench_test.go | 428 ++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 cmd/entire/cli/checkpoint/bench_test.go diff --git a/cmd/entire/cli/checkpoint/bench_test.go b/cmd/entire/cli/checkpoint/bench_test.go new file mode 100644 index 000000000..af68aea06 --- /dev/null +++ b/cmd/entire/cli/checkpoint/bench_test.go @@ -0,0 +1,428 @@ +package checkpoint_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/benchutil" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/paths" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// --- WriteTemporary benchmarks --- +// WriteTemporary is the hot path that fires on every agent turn (SaveStep). +// It builds a git tree from changed files and commits to the shadow branch. + +func BenchmarkWriteTemporary(b *testing.B) { + b.Run("FirstCheckpoint_SmallRepo", benchWriteTemporaryFirstCheckpoint(10, 100)) + b.Run("FirstCheckpoint_LargeRepo", benchWriteTemporaryFirstCheckpoint(50, 500)) + b.Run("Incremental_FewFiles", benchWriteTemporaryIncremental(3, 0, 0)) + b.Run("Incremental_ManyFiles", benchWriteTemporaryIncremental(30, 10, 5)) + b.Run("Incremental_LargeFiles", benchWriteTemporaryIncrementalLargeFiles(2, 10000)) + b.Run("Dedup_NoChanges", benchWriteTemporaryDedup()) + b.Run("ManyPriorCheckpoints", benchWriteTemporaryWithHistory(50)) +} + +// benchWriteTemporaryFirstCheckpoint benchmarks the first checkpoint of a session. +// The first checkpoint captures all changed files via `git status`, which is heavier +// than incremental checkpoints. +func benchWriteTemporaryFirstCheckpoint(fileCount, fileSizeLines int) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: fileCount, + FileSizeLines: fileSizeLines, + }) + sessionID := repo.CreateSessionState(b, benchutil.SessionOpts{}) + + // Modify a few files to create a dirty working directory + for i := range 3 { + name := fmt.Sprintf("src/file_%03d.go", i) + repo.WriteFile(b, name, benchutil.GenerateGoFile(9000+i, fileSizeLines)) + } + + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + metadataDirAbs := filepath.Join(repo.Dir, metadataDir) + if err := os.MkdirAll(metadataDirAbs, 0o750); err != nil { + b.Fatalf("mkdir metadata: %v", err) + } + + // WriteTemporary uses paths.RepoRoot() which requires cwd to be in the repo. + b.Chdir(repo.Dir) + + ctx := context.Background() + b.ResetTimer() + for i := range b.N { + // Re-create the shadow branch state for each iteration so we always + // measure the first-checkpoint path (which runs collectChangedFiles). + // We use a unique session ID per iteration to get a fresh shadow branch. + sid := fmt.Sprintf("bench-first-%d", i) + _, writeErr := repo.Store.WriteTemporary(ctx, checkpoint.WriteTemporaryOptions{ + SessionID: sid, + BaseCommit: repo.HeadHash, + WorktreeID: repo.WorktreeID, + ModifiedFiles: []string{"src/file_000.go", "src/file_001.go", "src/file_002.go"}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "benchmark checkpoint", + AuthorName: "Bench", + AuthorEmail: "bench@test.com", + IsFirstCheckpoint: true, + }) + if writeErr != nil { + b.Fatalf("WriteTemporary: %v", writeErr) + } + } + } +} + +// benchWriteTemporaryIncremental benchmarks subsequent checkpoints (not the first). +// These skip collectChangedFiles and only process the provided file lists. +func benchWriteTemporaryIncremental(modified, newFiles, deleted int) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: max(modified+newFiles, 10), + FileSizeLines: 100, + }) + sessionID := repo.CreateSessionState(b, benchutil.SessionOpts{}) + + // Seed one checkpoint so subsequent ones are not IsFirstCheckpoint + repo.SeedShadowBranch(b, sessionID, 1, 3) + + // Prepare file lists + modifiedFiles := make([]string, 0, modified) + for i := range modified { + name := fmt.Sprintf("src/file_%03d.go", i) + repo.WriteFile(b, name, benchutil.GenerateGoFile(8000+i, 100)) + modifiedFiles = append(modifiedFiles, name) + } + newFileList := make([]string, 0, newFiles) + for i := range newFiles { + name := fmt.Sprintf("src/new_%03d.go", i) + repo.WriteFile(b, name, benchutil.GenerateGoFile(7000+i, 100)) + newFileList = append(newFileList, name) + } + deletedFiles := make([]string, 0, deleted) + for i := range deleted { + deletedFiles = append(deletedFiles, fmt.Sprintf("src/file_%03d.go", modified+newFiles+i)) + } + + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + metadataDirAbs := filepath.Join(repo.Dir, metadataDir) + if err := os.MkdirAll(metadataDirAbs, 0o750); err != nil { + b.Fatalf("mkdir metadata: %v", err) + } + + b.Chdir(repo.Dir) + + ctx := context.Background() + b.ResetTimer() + for range b.N { + _, writeErr := repo.Store.WriteTemporary(ctx, checkpoint.WriteTemporaryOptions{ + SessionID: sessionID, + BaseCommit: repo.HeadHash, + WorktreeID: repo.WorktreeID, + ModifiedFiles: modifiedFiles, + NewFiles: newFileList, + DeletedFiles: deletedFiles, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "benchmark checkpoint", + AuthorName: "Bench", + AuthorEmail: "bench@test.com", + IsFirstCheckpoint: false, + }) + if writeErr != nil { + b.Fatalf("WriteTemporary: %v", writeErr) + } + } + } +} + +// benchWriteTemporaryIncrementalLargeFiles benchmarks checkpoints with large files. +func benchWriteTemporaryIncrementalLargeFiles(fileCount, linesPerFile int) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: fileCount, + FileSizeLines: linesPerFile, + }) + sessionID := repo.CreateSessionState(b, benchutil.SessionOpts{}) + repo.SeedShadowBranch(b, sessionID, 1, fileCount) + + modifiedFiles := make([]string, 0, fileCount) + for i := range fileCount { + name := fmt.Sprintf("src/file_%03d.go", i) + repo.WriteFile(b, name, benchutil.GenerateGoFile(6000+i, linesPerFile)) + modifiedFiles = append(modifiedFiles, name) + } + + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + metadataDirAbs := filepath.Join(repo.Dir, metadataDir) + if err := os.MkdirAll(metadataDirAbs, 0o750); err != nil { + b.Fatalf("mkdir metadata: %v", err) + } + + b.Chdir(repo.Dir) + + ctx := context.Background() + b.ResetTimer() + for range b.N { + _, writeErr := repo.Store.WriteTemporary(ctx, checkpoint.WriteTemporaryOptions{ + SessionID: sessionID, + BaseCommit: repo.HeadHash, + WorktreeID: repo.WorktreeID, + ModifiedFiles: modifiedFiles, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "benchmark checkpoint", + AuthorName: "Bench", + AuthorEmail: "bench@test.com", + IsFirstCheckpoint: false, + }) + if writeErr != nil { + b.Fatalf("WriteTemporary: %v", writeErr) + } + } + } +} + +// benchWriteTemporaryDedup benchmarks the dedup fast-path where the tree hash +// matches the previous checkpoint, so the write is skipped. +func benchWriteTemporaryDedup() func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{FileCount: 10}) + sessionID := repo.CreateSessionState(b, benchutil.SessionOpts{}) + repo.SeedShadowBranch(b, sessionID, 1, 3) + + // Don't modify any files — tree will match the previous checkpoint + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + metadataDirAbs := filepath.Join(repo.Dir, metadataDir) + + b.Chdir(repo.Dir) + + ctx := context.Background() + b.ResetTimer() + for range b.N { + result, writeErr := repo.Store.WriteTemporary(ctx, checkpoint.WriteTemporaryOptions{ + SessionID: sessionID, + BaseCommit: repo.HeadHash, + WorktreeID: repo.WorktreeID, + ModifiedFiles: []string{"src/file_000.go", "src/file_001.go", "src/file_002.go"}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "benchmark checkpoint", + AuthorName: "Bench", + AuthorEmail: "bench@test.com", + IsFirstCheckpoint: false, + }) + if writeErr != nil { + b.Fatalf("WriteTemporary: %v", writeErr) + } + if !result.Skipped { + b.Fatal("expected dedup skip") + } + } + } +} + +// benchWriteTemporaryWithHistory benchmarks WriteTemporary when the shadow branch +// already has many prior checkpoint commits. +func benchWriteTemporaryWithHistory(priorCheckpoints int) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{FileCount: 10}) + sessionID := repo.CreateSessionState(b, benchutil.SessionOpts{}) + repo.SeedShadowBranch(b, sessionID, priorCheckpoints, 3) + + // Modify files for the new checkpoint + for i := range 3 { + name := fmt.Sprintf("src/file_%03d.go", i) + repo.WriteFile(b, name, benchutil.GenerateGoFile(5000+i, 100)) + } + + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + metadataDirAbs := filepath.Join(repo.Dir, metadataDir) + if err := os.MkdirAll(metadataDirAbs, 0o750); err != nil { + b.Fatalf("mkdir metadata: %v", err) + } + + b.Chdir(repo.Dir) + + ctx := context.Background() + b.ResetTimer() + for range b.N { + _, writeErr := repo.Store.WriteTemporary(ctx, checkpoint.WriteTemporaryOptions{ + SessionID: sessionID, + BaseCommit: repo.HeadHash, + WorktreeID: repo.WorktreeID, + ModifiedFiles: []string{"src/file_000.go", "src/file_001.go", "src/file_002.go"}, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: "benchmark checkpoint", + AuthorName: "Bench", + AuthorEmail: "bench@test.com", + IsFirstCheckpoint: false, + }) + if writeErr != nil { + b.Fatalf("WriteTemporary: %v", writeErr) + } + } + } +} + +// --- WriteCommitted benchmarks --- +// WriteCommitted fires during PostCommit condensation when the user does `git commit`. +// It writes session metadata to the entire/checkpoints/v1 branch. + +func BenchmarkWriteCommitted(b *testing.B) { + b.Run("SmallTranscript", benchWriteCommitted(20, 500, 3, 0)) + b.Run("MediumTranscript", benchWriteCommitted(200, 500, 15, 0)) + b.Run("LargeTranscript", benchWriteCommitted(2000, 500, 50, 0)) + b.Run("HugeTranscript", benchWriteCommitted(10000, 1000, 100, 0)) + b.Run("EmptyMetadataBranch", benchWriteCommitted(200, 500, 15, 0)) + b.Run("FewPriorCheckpoints", benchWriteCommitted(200, 500, 15, 10)) + b.Run("ManyPriorCheckpoints", benchWriteCommitted(200, 500, 15, 200)) +} + +// benchWriteCommitted benchmarks writing to the entire/checkpoints/v1 branch. +func benchWriteCommitted(messageCount, avgMsgBytes, filesTouched, priorCheckpoints int) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: max(filesTouched, 10), + }) + + // Seed prior checkpoints if requested + if priorCheckpoints > 0 { + repo.SeedMetadataBranch(b, priorCheckpoints) + } + + // Pre-generate transcript data (not part of the benchmark) + files := make([]string, 0, filesTouched) + for i := range filesTouched { + files = append(files, fmt.Sprintf("src/file_%03d.go", i)) + } + transcript := benchutil.GenerateTranscript(benchutil.TranscriptOpts{ + MessageCount: messageCount, + AvgMessageBytes: avgMsgBytes, + IncludeToolUse: true, + FilesTouched: files, + }) + prompts := []string{"Implement the feature", "Fix the bug in handler"} + + b.ResetTimer() + b.ReportMetric(float64(len(transcript)), "transcript_bytes") + + ctx := context.Background() + for i := range b.N { + cpID, err := id.Generate() + if err != nil { + b.Fatalf("generate ID: %v", err) + } + err = repo.Store.WriteCommitted(ctx, checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: fmt.Sprintf("bench-session-%d", i), + Strategy: "manual-commit", + Transcript: transcript, + Prompts: prompts, + FilesTouched: files, + CheckpointsCount: 5, + AuthorName: "Bench", + AuthorEmail: "bench@test.com", + }) + if err != nil { + b.Fatalf("WriteCommitted: %v", err) + } + } + } +} + +// --- FlattenTree + BuildTreeFromEntries benchmarks --- +// These isolate the git plumbing cost that's shared by both hot paths. + +func BenchmarkFlattenTree(b *testing.B) { + b.Run("10files", benchFlattenTree(10, 100)) + b.Run("50files", benchFlattenTree(50, 100)) + b.Run("200files", benchFlattenTree(200, 50)) +} + +func benchFlattenTree(fileCount, fileSizeLines int) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: fileCount, + FileSizeLines: fileSizeLines, + }) + + // Get HEAD tree + head, err := repo.Repo.Head() + if err != nil { + b.Fatalf("head: %v", err) + } + commit, err := repo.Repo.CommitObject(head.Hash()) + if err != nil { + b.Fatalf("commit: %v", err) + } + tree, err := commit.Tree() + if err != nil { + b.Fatalf("tree: %v", err) + } + + b.ResetTimer() + for range b.N { + entries := make(map[string]object.TreeEntry, fileCount) + if err := checkpoint.FlattenTree(repo.Repo, tree, "", entries); err != nil { + b.Fatalf("FlattenTree: %v", err) + } + } + } +} + +func BenchmarkBuildTreeFromEntries(b *testing.B) { + b.Run("10entries", benchBuildTree(10)) + b.Run("50entries", benchBuildTree(50)) + b.Run("200entries", benchBuildTree(200)) +} + +func benchBuildTree(entryCount int) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{ + FileCount: entryCount, + }) + + // Flatten the HEAD tree to get realistic entries + head, err := repo.Repo.Head() + if err != nil { + b.Fatalf("head: %v", err) + } + commit, err := repo.Repo.CommitObject(head.Hash()) + if err != nil { + b.Fatalf("commit: %v", err) + } + tree, err := commit.Tree() + if err != nil { + b.Fatalf("tree: %v", err) + } + entries := make(map[string]object.TreeEntry, entryCount) + if err := checkpoint.FlattenTree(repo.Repo, tree, "", entries); err != nil { + b.Fatalf("FlattenTree: %v", err) + } + + // Open a fresh repo handle for building (to avoid storer cache effects) + freshRepo, err := gogit.PlainOpen(repo.Dir) + if err != nil { + b.Fatalf("open: %v", err) + } + + b.ResetTimer() + for range b.N { + _, buildErr := checkpoint.BuildTreeFromEntries(freshRepo, entries) + if buildErr != nil { + b.Fatalf("BuildTreeFromEntries: %v", buildErr) + } + } + } +} From be1fc53dc89e31ee43008bc28e5bb83faf01f358 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 12:15:40 -0800 Subject: [PATCH 2/5] add entire status benchmark --- cmd/entire/cli/bench_test.go | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 cmd/entire/cli/bench_test.go diff --git a/cmd/entire/cli/bench_test.go b/cmd/entire/cli/bench_test.go new file mode 100644 index 000000000..dd672b060 --- /dev/null +++ b/cmd/entire/cli/bench_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "io" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/benchutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// BenchmarkStatusCommand benchmarks the `entire status` command end-to-end. +// This is the top-level entry point for understanding status command latency. +// +// Key I/O operations measured: +// - git rev-parse --show-toplevel (RepoRoot, cached after first call) +// - git rev-parse --git-common-dir (NewStateStore, per invocation) +// - git rev-parse --abbrev-ref HEAD (resolveWorktreeBranch, per unique worktree) +// - os.ReadFile for settings.json, each session state file +// - JSON unmarshaling for settings and each session state +// +// The primary scaling dimension is active session count. +func BenchmarkStatusCommand(b *testing.B) { + b.Run("Short/NoSessions", benchStatus(0, false)) + b.Run("Short/1Session", benchStatus(1, false)) + b.Run("Short/5Sessions", benchStatus(5, false)) + b.Run("Short/10Sessions", benchStatus(10, false)) + b.Run("Short/20Sessions", benchStatus(20, false)) + b.Run("Detailed/NoSessions", benchStatus(0, true)) + b.Run("Detailed/5Sessions", benchStatus(5, true)) +} + +func benchStatus(sessionCount int, detailed bool) func(*testing.B) { + return func(b *testing.B) { + repo := benchutil.NewBenchRepo(b, benchutil.RepoOpts{}) + + // Create active session state files in .git/entire-sessions/ + for range sessionCount { + repo.CreateSessionState(b, benchutil.SessionOpts{}) + } + + // runStatus uses paths.RepoRoot() which requires cwd to be in the repo. + b.Chdir(repo.Dir) + paths.ClearRepoRootCache() + + b.ResetTimer() + for range b.N { + // Clear cache each iteration to simulate a fresh CLI invocation. + // In real usage, each `entire status` call starts cold. + paths.ClearRepoRootCache() + + if err := runStatus(io.Discard, detailed); err != nil { + b.Fatalf("runStatus: %v", err) + } + } + } +} From 82c7928a27e56fa18a8336c26bba7e4413b1e2a8 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 12:52:25 -0800 Subject: [PATCH 3/5] process level cache for git common dir Entire-Checkpoint: 270d6b69d4fe --- cmd/entire/cli/bench_test.go | 13 +++++++++- cmd/entire/cli/session/state.go | 42 ++++++++++++++++++++++++++++++++- cmd/entire/cli/setup_test.go | 2 ++ top | 0 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 top diff --git a/cmd/entire/cli/bench_test.go b/cmd/entire/cli/bench_test.go index dd672b060..0039a8bca 100644 --- a/cmd/entire/cli/bench_test.go +++ b/cmd/entire/cli/bench_test.go @@ -6,8 +6,17 @@ import ( "github.com/entireio/cli/cmd/entire/cli/benchutil" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" ) +/* To use the interactive flame graph, run: + +mise exec -- go tool pprof -http=:8089 /tmp/status_cpu.prof &>/dev/null & echo "pprof server started on http://localhost:8089" + +and then go to http://localhost:8089/ui/flamegraph + +*/ + // BenchmarkStatusCommand benchmarks the `entire status` command end-to-end. // This is the top-level entry point for understanding status command latency. // @@ -41,12 +50,14 @@ func benchStatus(sessionCount int, detailed bool) func(*testing.B) { // runStatus uses paths.RepoRoot() which requires cwd to be in the repo. b.Chdir(repo.Dir) paths.ClearRepoRootCache() + session.ClearGitCommonDirCache() b.ResetTimer() for range b.N { - // Clear cache each iteration to simulate a fresh CLI invocation. + // Clear caches each iteration to simulate a fresh CLI invocation. // In real usage, each `entire status` call starts cold. paths.ClearRepoRootCache() + session.ClearGitCommonDirCache() if err := runStatus(io.Discard, detailed); err != nil { b.Fatalf("runStatus: %v", err) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 7ce4cbc02..0fed72ea7 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "github.com/entireio/cli/cmd/entire/cli/agent" @@ -364,10 +365,43 @@ func (s *StateStore) stateFilePath(sessionID string) string { return filepath.Join(s.stateDir, sessionID+".json") } +// gitCommonDirCache caches the git common dir to avoid repeated subprocess calls. +// Keyed by working directory to handle directory changes (same pattern as paths.RepoRoot). +var ( + gitCommonDirMu sync.RWMutex + gitCommonDirCache string + gitCommonDirCacheDir string +) + +// ClearGitCommonDirCache clears the cached git common dir. +// Useful for testing when changing directories. +func ClearGitCommonDirCache() { + gitCommonDirMu.Lock() + gitCommonDirCache = "" + gitCommonDirCacheDir = "" + gitCommonDirMu.Unlock() +} + // getGitCommonDir returns the path to the shared git directory. // In a regular checkout, this is .git/ // In a worktree, this is the main repo's .git/ (not .git/worktrees//) +// The result is cached per working directory. func getGitCommonDir() (string, error) { + cwd, err := os.Getwd() //nolint:forbidigo // used for cache key, not git-relative paths + if err != nil { + cwd = "" + } + + // Check cache with read lock first + gitCommonDirMu.RLock() + if gitCommonDirCache != "" && gitCommonDirCacheDir == cwd { + cached := gitCommonDirCache + gitCommonDirMu.RUnlock() + return cached, nil + } + gitCommonDirMu.RUnlock() + + // Cache miss — resolve via git subprocess ctx := context.Background() cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir") cmd.Dir = "." @@ -383,6 +417,12 @@ func getGitCommonDir() (string, error) { if !filepath.IsAbs(commonDir) { commonDir = filepath.Join(".", commonDir) } + commonDir = filepath.Clean(commonDir) + + gitCommonDirMu.Lock() + gitCommonDirCache = commonDir + gitCommonDirCacheDir = cwd + gitCommonDirMu.Unlock() - return filepath.Clean(commonDir), nil + return commonDir, nil } diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 2fe57bb9c..a5c4a08b5 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -12,6 +12,7 @@ import ( _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "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/go-git/go-git/v5" ) @@ -27,6 +28,7 @@ func setupTestDir(t *testing.T) string { tmpDir := t.TempDir() t.Chdir(tmpDir) paths.ClearRepoRootCache() + session.ClearGitCommonDirCache() return tmpDir } diff --git a/top b/top new file mode 100644 index 000000000..e69de29bb From cc204a359d5cfce7b63e2442777d0aa8f0a62468 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 13:04:12 -0800 Subject: [PATCH 4/5] add tests --- cmd/entire/cli/session/state_test.go | 117 +++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/cmd/entire/cli/session/state_test.go b/cmd/entire/cli/session/state_test.go index 99fe48428..83911abd5 100644 --- a/cmd/entire/cli/session/state_test.go +++ b/cmd/entire/cli/session/state_test.go @@ -2,8 +2,11 @@ package session import ( "encoding/json" + "os" + "path/filepath" "testing" + "github.com/go-git/go-git/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -118,3 +121,117 @@ func TestState_NormalizeAfterLoad_JSONRoundTrip(t *testing.T) { }) } } + +// initTestRepo creates a temp dir with a git repo and chdirs into it. +// Cannot use t.Parallel() because of t.Chdir. +func initTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + // Resolve symlinks (macOS /var -> /private/var) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + _, err := git.PlainInit(dir, false) + require.NoError(t, err) + t.Chdir(dir) + ClearGitCommonDirCache() + return dir +} + +func TestGetGitCommonDir_ReturnsValidPath(t *testing.T) { + dir := initTestRepo(t) + + commonDir, err := getGitCommonDir() + require.NoError(t, err) + + // getGitCommonDir returns a relative path from cwd; resolve it to absolute for comparison + absCommonDir, err := filepath.Abs(commonDir) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, ".git"), absCommonDir) + + // The path should actually exist + info, err := os.Stat(commonDir) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestGetGitCommonDir_CachesResult(t *testing.T) { + initTestRepo(t) + + // First call populates cache + first, err := getGitCommonDir() + require.NoError(t, err) + + // Second call should return the same result (from cache) + second, err := getGitCommonDir() + require.NoError(t, err) + + assert.Equal(t, first, second) +} + +func TestGetGitCommonDir_ClearCache(t *testing.T) { + initTestRepo(t) + + // Populate cache + _, err := getGitCommonDir() + require.NoError(t, err) + + // Verify cache is populated + gitCommonDirMu.RLock() + assert.NotEmpty(t, gitCommonDirCache) + gitCommonDirMu.RUnlock() + + // Clear and verify + ClearGitCommonDirCache() + + gitCommonDirMu.RLock() + assert.Empty(t, gitCommonDirCache) + assert.Empty(t, gitCommonDirCacheDir) + gitCommonDirMu.RUnlock() +} + +func TestGetGitCommonDir_InvalidatesOnCwdChange(t *testing.T) { + // Create two separate repos + dir1 := t.TempDir() + if resolved, err := filepath.EvalSymlinks(dir1); err == nil { + dir1 = resolved + } + _, err := git.PlainInit(dir1, false) + require.NoError(t, err) + + dir2 := t.TempDir() + if resolved, err := filepath.EvalSymlinks(dir2); err == nil { + dir2 = resolved + } + _, err = git.PlainInit(dir2, false) + require.NoError(t, err) + + ClearGitCommonDirCache() + + // Populate cache from dir1 + t.Chdir(dir1) + first, err := getGitCommonDir() + require.NoError(t, err) + absFirst, err := filepath.Abs(first) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir1, ".git"), absFirst) + + // Change to dir2 — cache should miss and resolve to dir2's .git + t.Chdir(dir2) + second, err := getGitCommonDir() + require.NoError(t, err) + absSecond, err := filepath.Abs(second) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir2, ".git"), absSecond) + + assert.NotEqual(t, absFirst, absSecond) +} + +func TestGetGitCommonDir_ErrorOutsideRepo(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + ClearGitCommonDirCache() + + _, err := getGitCommonDir() + assert.Error(t, err) +} From b9acd16609057bd0acbead73d86bc36c6e50756a Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 13:56:24 -0800 Subject: [PATCH 5/5] update run instrucs and seedshadowbranch --- cmd/entire/cli/benchutil/benchutil.go | 14 ++++++++++- cmd/entire/cli/benchutil/benchutil_test.go | 29 ++++++++++------------ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/cmd/entire/cli/benchutil/benchutil.go b/cmd/entire/cli/benchutil/benchutil.go index a382fa84c..76243035a 100644 --- a/cmd/entire/cli/benchutil/benchutil.go +++ b/cmd/entire/cli/benchutil/benchutil.go @@ -44,6 +44,9 @@ type BenchRepo struct { // WorktreeID is the worktree identifier (empty for main worktree). WorktreeID string + + // Strategy is the strategy name used in .entire/settings.json. + Strategy string } // RepoOpts configures how NewBenchRepo creates the test repository. @@ -167,6 +170,7 @@ func NewBenchRepo(b *testing.B, opts RepoOpts) *BenchRepo { Repo: repo, Store: checkpoint.NewGitStore(repo), HeadHash: headHash.String(), + Strategy: opts.Strategy, } // Determine worktree ID @@ -344,9 +348,17 @@ func (br *BenchRepo) WriteTranscriptFile(b *testing.B, sessionID string, data [] // SeedShadowBranch creates N checkpoint commits on the shadow branch // for the current HEAD. This simulates a session that already has // prior checkpoints saved. +// +// Temporarily changes cwd to br.Dir because WriteTemporary uses +// paths.RepoRoot() which depends on os.Getwd(). func (br *BenchRepo) SeedShadowBranch(b *testing.B, sessionID string, checkpointCount int, filesPerCheckpoint int) { b.Helper() + // WriteTemporary internally calls paths.RepoRoot() which uses os.Getwd(). + // Switch cwd so it resolves to the bench repo. + b.Chdir(br.Dir) + paths.ClearRepoRootCache() + for i := range checkpointCount { var modified []string for j := range filesPerCheckpoint { @@ -411,7 +423,7 @@ func (br *BenchRepo) SeedMetadataBranch(b *testing.B, checkpointCount int) { err = br.Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ CheckpointID: cpID, SessionID: sessionID, - Strategy: "manual-commit", + Strategy: br.Strategy, Transcript: transcript, Prompts: []string{fmt.Sprintf("Implement feature %d", i)}, FilesTouched: files, diff --git a/cmd/entire/cli/benchutil/benchutil_test.go b/cmd/entire/cli/benchutil/benchutil_test.go index 5dbb3bd41..e9e1208c0 100644 --- a/cmd/entire/cli/benchutil/benchutil_test.go +++ b/cmd/entire/cli/benchutil/benchutil_test.go @@ -1,7 +1,6 @@ package benchutil import ( - "fmt" "testing" "github.com/entireio/cli/cmd/entire/cli/session" @@ -23,25 +22,23 @@ func BenchmarkNewBenchRepo_Large(b *testing.B) { } func BenchmarkSeedShadowBranch(b *testing.B) { - for _, count := range []int{1, 5, 10} { - b.Run(fmt.Sprintf("%dCheckpoints", count), func(b *testing.B) { - for b.Loop() { - repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) - sessionID := repo.CreateSessionState(b, SessionOpts{}) - repo.SeedShadowBranch(b, sessionID, count, 3) - } - }) + for b.Loop() { + b.StopTimer() + repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) + sessionID := repo.CreateSessionState(b, SessionOpts{}) + b.StartTimer() + + repo.SeedShadowBranch(b, sessionID, 5, 3) } } func BenchmarkSeedMetadataBranch(b *testing.B) { - for _, count := range []int{1, 5, 10} { - b.Run(fmt.Sprintf("%dCheckpoints", count), func(b *testing.B) { - for b.Loop() { - repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) - repo.SeedMetadataBranch(b, count) - } - }) + for b.Loop() { + b.StopTimer() + repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) + b.StartTimer() + + repo.SeedMetadataBranch(b, 10) } }