diff --git a/cmd/entire/cli/bench_test.go b/cmd/entire/cli/bench_test.go new file mode 100644 index 000000000..4558c135e --- /dev/null +++ b/cmd/entire/cli/bench_test.go @@ -0,0 +1,85 @@ +package cli + +import ( + "io" + "testing" + + "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. +// +// 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, true)) + b.Run("Short/1Session", benchStatus(1, false, true)) + b.Run("Short/5Sessions", benchStatus(5, false, true)) + b.Run("Short/10Sessions", benchStatus(10, false, true)) + b.Run("Short/20Sessions", benchStatus(20, false, true)) + b.Run("Detailed/NoSessions", benchStatus(0, true, true)) + b.Run("Detailed/5Sessions", benchStatus(5, true, true)) +} + +// BenchmarkStatusCommand_NoCache simulates the old behavior where getGitCommonDir +// is uncached — every invocation spawns an extra git subprocess. +func BenchmarkStatusCommand_NoCache(b *testing.B) { + b.Run("Short/NoSessions", benchStatus(0, false, false)) + b.Run("Short/1Session", benchStatus(1, false, false)) + b.Run("Short/5Sessions", benchStatus(5, false, false)) + b.Run("Short/10Sessions", benchStatus(10, false, false)) + b.Run("Short/20Sessions", benchStatus(20, false, false)) + b.Run("Detailed/NoSessions", benchStatus(0, true, false)) + b.Run("Detailed/5Sessions", benchStatus(5, true, false)) +} + +// benchStatus returns a benchmark function for the `entire status` command. +// When useGitCommonDirCache is false, it clears the git common dir cache each +// iteration to simulate the old uncached behavior. +func benchStatus(sessionCount int, detailed, useGitCommonDirCache 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() + session.ClearGitCommonDirCache() + + b.ResetTimer() + for range b.N { + // Always clear RepoRoot to simulate a fresh CLI invocation. + paths.ClearRepoRootCache() + + if !useGitCommonDirCache { + // Simulate old behavior: no git common dir cache. + session.ClearGitCommonDirCache() + } + + if err := runStatus(io.Discard, detailed); err != nil { + b.Fatalf("runStatus: %v", err) + } + } + } +} 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/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) + } + } + } +} diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 02402b06b..c39d63e48 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" @@ -382,10 +383,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 = "." @@ -401,6 +435,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/session/state_test.go b/cmd/entire/cli/session/state_test.go index 0d80d635c..95b966be1 100644 --- a/cmd/entire/cli/session/state_test.go +++ b/cmd/entire/cli/session/state_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/go-git/go-git/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -243,3 +244,117 @@ func TestStateStore_List_DeletesStaleSession(t *testing.T) { _, err = os.Stat(filepath.Join(stateDir, "active-session.json")) assert.NoError(t, err, "active session file should still exist") } + +// 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) +} 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/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index a81639451..c878cb208 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "os/exec" + "path/filepath" "sort" "strings" "time" @@ -178,7 +179,10 @@ type worktreeGroup struct { sessions []*session.State } -const unknownPlaceholder = "(unknown)" +const ( + unknownPlaceholder = "(unknown)" + detachedHEADDisplay = "HEAD" +) // writeActiveSessions writes active session information grouped by worktree. func writeActiveSessions(w io.Writer) { @@ -286,12 +290,72 @@ func writeActiveSessions(w io.Writer) { } } -// resolveWorktreeBranch resolves the current branch for a worktree path. +// resolveWorktreeBranch resolves the current branch for a worktree path +// by reading the HEAD ref directly from the filesystem func resolveWorktreeBranch(worktreePath string) string { - cmd := exec.CommandContext(context.Background(), "git", "-C", worktreePath, "rev-parse", "--abbrev-ref", "HEAD") - output, err := cmd.Output() + gitPath := filepath.Join(worktreePath, ".git") + + fi, err := os.Stat(gitPath) + if err != nil { + return "" + } + + var headPath string + if fi.IsDir() { + // Regular repo: .git is a directory + headPath = filepath.Join(gitPath, "HEAD") + } else { + // Worktree: .git is a file containing "gitdir: " + data, err := os.ReadFile(gitPath) //nolint:gosec // path derived from known worktree dir + if err != nil { + return "" + } + content := strings.TrimSpace(string(data)) + if !strings.HasPrefix(content, "gitdir: ") { + return "" + } + gitdirPath := strings.TrimPrefix(content, "gitdir: ") + if !filepath.IsAbs(gitdirPath) { + gitdirPath = filepath.Join(worktreePath, gitdirPath) + } + headPath = filepath.Join(gitdirPath, "HEAD") + } + + data, err := os.ReadFile(headPath) //nolint:gosec // path constructed from .git/HEAD if err != nil { return "" } - return strings.TrimSpace(string(output)) + + ref := strings.TrimSpace(string(data)) + + // Symbolic ref: "ref: refs/heads/" + if strings.HasPrefix(ref, "ref: refs/heads/") { + branch := strings.TrimPrefix(ref, "ref: refs/heads/") + // Reftable ref storage uses "ref: refs/heads/.invalid" as a dummy HEAD stub. + // Fall back to git to resolve the actual branch in that case. + if branch == ".invalid" { + return resolveWorktreeBranchGit(worktreePath) + } + return branch + } + + // Detached HEAD or other ref type + return detachedHEADDisplay +} + +// resolveWorktreeBranchGit resolves the branch name by shelling out to git. +// Used as a fallback for reftable ref storage where .git/HEAD is a stub. +func resolveWorktreeBranchGit(worktreePath string) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rev-parse", "--symbolic-full-name", "HEAD") + out, err := cmd.Output() + if err != nil { + return detachedHEADDisplay + } + ref := strings.TrimSpace(string(out)) + if strings.HasPrefix(ref, "refs/heads/") { + return strings.TrimPrefix(ref, "refs/heads/") + } + return detachedHEADDisplay } diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index a54dfb983..4583838a1 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -3,14 +3,194 @@ package cli import ( "bytes" "context" + "os" + "path/filepath" "strings" "testing" "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/session" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" ) +func TestResolveWorktreeBranch_RegularRepo(t *testing.T) { + dir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + _, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("git init: %v", err) + } + + // Read the default branch name directly from HEAD to avoid hard-coding it + headData, err := os.ReadFile(filepath.Join(dir, ".git", "HEAD")) + if err != nil { + t.Fatalf("read HEAD: %v", err) + } + wantBranch := strings.TrimPrefix(strings.TrimSpace(string(headData)), "ref: refs/heads/") + + branch := resolveWorktreeBranch(dir) + if branch != wantBranch { + t.Errorf("resolveWorktreeBranch() = %q, want %q", branch, wantBranch) + } +} + +func TestResolveWorktreeBranch_DetachedHEAD(t *testing.T) { + dir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + repo, err := git.PlainInit(dir, false) + if err != nil { + t.Fatalf("git init: %v", err) + } + + // Create a commit so we can detach HEAD + wt, err := repo.Worktree() + if err != nil { + t.Fatalf("worktree: %v", err) + } + testFile := filepath.Join(dir, "test.txt") + if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + if _, err := wt.Add("test.txt"); err != nil { + t.Fatalf("add: %v", err) + } + hash, err := wt.Commit("initial", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("commit: %v", err) + } + + // Detach HEAD by writing the raw hash to .git/HEAD + headPath := filepath.Join(dir, ".git", "HEAD") + if err := os.WriteFile(headPath, []byte(hash.String()+"\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } + + branch := resolveWorktreeBranch(dir) + if branch != "HEAD" { + t.Errorf("resolveWorktreeBranch() = %q, want %q for detached HEAD", branch, "HEAD") + } +} + +func TestResolveWorktreeBranch_WorktreeGitFile(t *testing.T) { + // Simulate a worktree where .git is a file pointing to a gitdir + dir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + // Create a fake gitdir with a HEAD file + gitdir := filepath.Join(dir, "fake-gitdir") + if err := os.MkdirAll(gitdir, 0o755); err != nil { + t.Fatalf("mkdir gitdir: %v", err) + } + headPath := filepath.Join(gitdir, "HEAD") + if err := os.WriteFile(headPath, []byte("ref: refs/heads/feature-branch\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } + + // Create a worktree-style .git file + worktreeDir := filepath.Join(dir, "worktree") + if err := os.MkdirAll(worktreeDir, 0o755); err != nil { + t.Fatalf("mkdir worktree: %v", err) + } + gitFile := filepath.Join(worktreeDir, ".git") + if err := os.WriteFile(gitFile, []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil { + t.Fatalf("write .git file: %v", err) + } + + branch := resolveWorktreeBranch(worktreeDir) + if branch != "feature-branch" { + t.Errorf("resolveWorktreeBranch() = %q, want %q", branch, "feature-branch") + } +} + +func TestResolveWorktreeBranch_WorktreeRelativePath(t *testing.T) { + // Simulate a worktree where .git file uses a relative gitdir path + dir := t.TempDir() + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + // Create the main .git dir structure + mainGitDir := filepath.Join(dir, "main-repo", ".git", "worktrees", "wt1") + if err := os.MkdirAll(mainGitDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + headPath := filepath.Join(mainGitDir, "HEAD") + if err := os.WriteFile(headPath, []byte("ref: refs/heads/develop\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } + + // Create worktree directory with relative .git file + worktreeDir := filepath.Join(dir, "main-repo", "worktrees-dir", "wt1") + if err := os.MkdirAll(worktreeDir, 0o755); err != nil { + t.Fatalf("mkdir worktree: %v", err) + } + // Relative path from worktree to the gitdir + relPath := filepath.Join("..", "..", ".git", "worktrees", "wt1") + gitFile := filepath.Join(worktreeDir, ".git") + if err := os.WriteFile(gitFile, []byte("gitdir: "+relPath+"\n"), 0o644); err != nil { + t.Fatalf("write .git file: %v", err) + } + + branch := resolveWorktreeBranch(worktreeDir) + if branch != "develop" { + t.Errorf("resolveWorktreeBranch() = %q, want %q", branch, "develop") + } +} + +func TestResolveWorktreeBranch_NonExistentPath(t *testing.T) { + t.Parallel() + branch := resolveWorktreeBranch("/nonexistent/path/that/does/not/exist") + if branch != "" { + t.Errorf("resolveWorktreeBranch() = %q, want empty string for non-existent path", branch) + } +} + +func TestResolveWorktreeBranch_NotARepo(t *testing.T) { + dir := t.TempDir() + // No .git directory or file + branch := resolveWorktreeBranch(dir) + if branch != "" { + t.Errorf("resolveWorktreeBranch() = %q, want empty string for non-repo directory", branch) + } +} + +func TestResolveWorktreeBranch_ReftableStub(t *testing.T) { + t.Parallel() + + // Simulate a reftable repo where .git/HEAD contains "ref: refs/heads/.invalid" + dir := t.TempDir() + gitDir := filepath.Join(dir, ".git") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/.invalid\n"), 0o644); err != nil { + t.Fatalf("write HEAD: %v", err) + } + + branch := resolveWorktreeBranch(dir) + // Should fall back to git, which will fail on this fake repo and return "HEAD" + if branch != "HEAD" { + t.Errorf("resolveWorktreeBranch() = %q, want %q for reftable stub", branch, "HEAD") + } +} + func TestRunStatus_Enabled(t *testing.T) { setupTestRepo(t) writeSettings(t, testSettingsEnabled) diff --git a/cpu.prof b/cpu.prof new file mode 100644 index 000000000..b5d77d950 Binary files /dev/null and b/cpu.prof differ diff --git a/mise.toml b/mise.toml index 38116a7bb..cfaa903b8 100644 --- a/mise.toml +++ b/mise.toml @@ -97,29 +97,77 @@ git diff --cached --name-only -z --diff-filter=ACM | grep -z '\\.go$' | xargs -0 [tasks.bench] description = "Run all benchmarks" -run = "go test -bench=. -benchmem -run='^$' -timeout=10m ./..." +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +output=$(go test -bench=. -benchmem -run='^$' -timeout=10m ./... 2>&1) + +# Print non-benchmark lines (ok, PASS, etc.) to stderr +echo "$output" | grep -v '^Benchmark' >&2 + +# Format benchmark lines as a table with headers using column +bench_lines=$(echo "$output" | grep '^Benchmark' || true) +if [ -n "$bench_lines" ]; then + { + echo "BENCHMARK ITERS MS/OP NS/OP B/OP ALLOCS/OP" + echo "--------- ----- ----- ----- ---- ---------" + # Strip unit suffixes and add ms/op column (ns/op / 1000000) + echo "$bench_lines" | sed 's/ ns\/op//g; s/ B\/op//g; s/ allocs\/op//g; s/ transcript_bytes//g' \ + | awk '{printf "%s %s %.2f %s %s %s\n", $1, $2, $3/1000000, $3, $4, $5}' + } | column -t +fi +''' [tasks."bench:cpu"] -description = "Run benchmarks with CPU profile (single package)" -run = """ +description = "Run benchmarks with CPU profile (pass package as arg, default: ./cmd/entire/cli/)" +run = ''' #!/usr/bin/env bash set -euo pipefail -PKG="${BENCH_PKG:-./cmd/entire/cli/benchutil/}" -echo "Profiling package: $PKG (override with BENCH_PKG=./path/to/pkg)" -go test -bench=. -benchmem -run='^$' -cpuprofile=cpu.prof -timeout=10m "$PKG" -echo "Profile saved to cpu.prof. View with: go tool pprof -http=:8080 cpu.prof" -""" + +pkg="${1:-./cmd/entire/cli/}" +output=$(go test -bench=. -benchmem -run='^$' -cpuprofile=cpu.prof -timeout=10m "$pkg" 2>&1) + +echo "$output" | grep -v '^Benchmark' >&2 + +bench_lines=$(echo "$output" | grep '^Benchmark' || true) +if [ -n "$bench_lines" ]; then + { + echo "BENCHMARK ITERS MS/OP NS/OP B/OP ALLOCS/OP" + echo "--------- ----- ----- ----- ---- ---------" + echo "$bench_lines" | sed 's/ ns\/op//g; s/ B\/op//g; s/ allocs\/op//g; s/ transcript_bytes//g' \ + | awk '{printf "%s %s %.2f %s %s %s\n", $1, $2, $3/1000000, $3, $4, $5}' + } | column -t +fi + +echo "" +echo "CPU profile saved to cpu.prof. View with: go tool pprof -http=:8080 cpu.prof" +''' [tasks."bench:mem"] -description = "Run benchmarks with memory profile (single package)" -run = """ +description = "Run benchmarks with memory profile (pass package as arg, default: ./cmd/entire/cli/)" +run = ''' #!/usr/bin/env bash set -euo pipefail -PKG="${BENCH_PKG:-./cmd/entire/cli/benchutil/}" -echo "Profiling package: $PKG (override with BENCH_PKG=./path/to/pkg)" -go test -bench=. -benchmem -run='^$' -memprofile=mem.prof -timeout=10m "$PKG" -echo "Profile saved to mem.prof. View with: go tool pprof -http=:8080 mem.prof" -""" + +pkg="${1:-./cmd/entire/cli/}" +output=$(go test -bench=. -benchmem -run='^$' -memprofile=mem.prof -timeout=10m "$pkg" 2>&1) + +echo "$output" | grep -v '^Benchmark' >&2 + +bench_lines=$(echo "$output" | grep '^Benchmark' || true) +if [ -n "$bench_lines" ]; then + { + echo "BENCHMARK ITERS MS/OP NS/OP B/OP ALLOCS/OP" + echo "--------- ----- ----- ----- ---- ---------" + echo "$bench_lines" | sed 's/ ns\/op//g; s/ B\/op//g; s/ allocs\/op//g; s/ transcript_bytes//g' \ + | awk '{printf "%s %s %.2f %s %s %s\n", $1, $2, $3/1000000, $3, $4, $5}' + } | column -t +fi + +echo "" +echo "Memory profile saved to mem.prof. View with: go tool pprof -http=:8080 mem.prof" +''' [tasks."bench:compare"] description = "Compare benchmarks between current branch and main" diff --git a/top b/top new file mode 100644 index 000000000..e69de29bb