diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 762a0966d..3317cd390 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ Contributions and communications are expected to occur through: - [GitHub Issues](https://github.com/entireio/cli/issues) - Bug reports and feature requests - [GitHub Discussions](https://github.com/entireio/cli/discussions) - Questions and general conversation -- [Discord](https://discord.gg/4WXDu2Ph) - Real-time chat and support +- [Discord](https://discord.gg/jZJs3Tue4S) - Real-time chat and support Please represent the project and community respectfully in all public and private interactions. @@ -329,7 +329,7 @@ Join the Entire community: - **Discord** - [Join our server][discord] for discussions and support - **GitHub Discussions** - [Join the conversation][discussions] -[discord]: https://discord.gg/4WXDu2Ph +[discord]: https://discord.gg/jZJs3Tue4S [discussions]: https://github.com/entireio/cli/discussions --- diff --git a/cmd/entire/cli/bench_test.go b/cmd/entire/cli/bench_test.go new file mode 100644 index 000000000..0039a8bca --- /dev/null +++ b/cmd/entire/cli/bench_test.go @@ -0,0 +1,67 @@ +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)) + 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() + session.ClearGitCommonDirCache() + + b.ResetTimer() + for range b.N { + // 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/benchutil/benchutil.go b/cmd/entire/cli/benchutil/benchutil.go new file mode 100644 index 000000000..76243035a --- /dev/null +++ b/cmd/entire/cli/benchutil/benchutil.go @@ -0,0 +1,562 @@ +// Package benchutil provides test fixture helpers for CLI benchmarks. +// +// It creates realistic git repositories, transcripts, session states, +// and checkpoint data for benchmarking the hot paths (SaveStep, PostCommit/Condense). +package benchutil + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +// BenchRepo is a fully initialized git repository with Entire configured, +// ready for checkpoint benchmarks. +type BenchRepo struct { + // Dir is the absolute path to the repository root. + Dir string + + // Repo is the go-git repository handle. + Repo *git.Repository + + // Store is the checkpoint GitStore for this repo. + Store *checkpoint.GitStore + + // HeadHash is the current HEAD commit hash string. + HeadHash string + + // 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. +type RepoOpts struct { + // FileCount is the number of tracked files to create in the initial commit. + // Each file is ~100 lines of Go code. Defaults to 10. + FileCount int + + // FileSizeLines is the number of lines per file. Defaults to 100. + FileSizeLines int + + // CommitCount is the number of commits to create. Defaults to 1. + CommitCount int + + // Strategy is the strategy name for .entire/settings.json. + // Defaults to "manual-commit". + Strategy string + + // FeatureBranch, if non-empty, creates and checks out this branch + // after the initial commits. + FeatureBranch string +} + +func (o *RepoOpts) withDefaults() RepoOpts { + out := *o + if out.FileCount == 0 { + out.FileCount = 10 + } + if out.FileSizeLines == 0 { + out.FileSizeLines = 100 + } + if out.CommitCount == 0 { + out.CommitCount = 1 + } + if out.Strategy == "" { + out.Strategy = "manual-commit" + } + return out +} + +// NewBenchRepo creates an isolated git repository for benchmarks. +// The repo has an initial commit with the configured number of files, +// a .gitignore excluding .entire/, and Entire settings initialized. +// +// Uses b.TempDir() so cleanup is automatic. +func NewBenchRepo(b *testing.B, opts RepoOpts) *BenchRepo { + b.Helper() + opts = opts.withDefaults() + + dir := b.TempDir() + // Resolve symlinks (macOS /var -> /private/var) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved + } + + // Init repo + repo, err := git.PlainInit(dir, false) + if err != nil { + b.Fatalf("git init: %v", err) + } + + // Create .gitignore and .entire settings + writeFile(b, dir, ".gitignore", ".entire/\n") + initEntireSettings(b, dir, opts.Strategy) + + // Generate initial files + wt, err := repo.Worktree() + if err != nil { + b.Fatalf("worktree: %v", err) + } + + for i := range opts.FileCount { + name := fmt.Sprintf("src/file_%03d.go", i) + content := GenerateGoFile(i, opts.FileSizeLines) + writeFile(b, dir, name, content) + if _, err := wt.Add(name); err != nil { + b.Fatalf("add %s: %v", name, err) + } + } + if _, err := wt.Add(".gitignore"); err != nil { + b.Fatalf("add .gitignore: %v", err) + } + + // Create commits + var headHash plumbing.Hash + for c := range opts.CommitCount { + if c > 0 { + // Modify a file for subsequent commits + name := fmt.Sprintf("src/file_%03d.go", c%opts.FileCount) + content := GenerateGoFile(c*1000, opts.FileSizeLines) + writeFile(b, dir, name, content) + if _, err := wt.Add(name); err != nil { + b.Fatalf("add %s: %v", name, err) + } + } + headHash, err = wt.Commit(fmt.Sprintf("Commit %d", c+1), &git.CommitOptions{ + Author: &object.Signature{ + Name: "Bench User", + Email: "bench@example.com", + When: time.Now(), + }, + }) + if err != nil { + b.Fatalf("commit %d: %v", c+1, err) + } + } + + // Optionally create feature branch + if opts.FeatureBranch != "" { + ref := plumbing.NewHashReference( + plumbing.NewBranchReferenceName(opts.FeatureBranch), headHash) + if err := repo.Storer.SetReference(ref); err != nil { + b.Fatalf("create branch: %v", err) + } + // Checkout via git CLI (go-git v5 checkout bug) + checkoutBranch(b, dir, opts.FeatureBranch) + } + + br := &BenchRepo{ + Dir: dir, + Repo: repo, + Store: checkpoint.NewGitStore(repo), + HeadHash: headHash.String(), + Strategy: opts.Strategy, + } + + // Determine worktree ID + wtID, err := paths.GetWorktreeID(dir) + if err == nil { + br.WorktreeID = wtID + } + + return br +} + +// WriteFile creates or overwrites a file relative to the repo root. +func (br *BenchRepo) WriteFile(b *testing.B, relPath, content string) { + b.Helper() + writeFile(b, br.Dir, relPath, content) +} + +// AddAndCommit stages the given files and creates a commit. +// Returns the new HEAD hash. +func (br *BenchRepo) AddAndCommit(b *testing.B, message string, files ...string) string { + b.Helper() + wt, err := br.Repo.Worktree() + if err != nil { + b.Fatalf("worktree: %v", err) + } + for _, f := range files { + if _, err := wt.Add(f); err != nil { + b.Fatalf("add %s: %v", f, err) + } + } + hash, err := wt.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Bench User", + Email: "bench@example.com", + When: time.Now(), + }, + }) + if err != nil { + b.Fatalf("commit: %v", err) + } + br.HeadHash = hash.String() + return hash.String() +} + +// SessionOpts configures how CreateSessionState creates a session state file. +type SessionOpts struct { + // SessionID is the session identifier. Auto-generated if empty. + SessionID string + + // Phase is the session phase. Defaults to session.PhaseActive. + Phase session.Phase + + // StepCount is the number of prior checkpoints. Defaults to 0. + StepCount int + + // FilesTouched is the list of files tracked by this session. + FilesTouched []string + + // TranscriptPath is the path to the live transcript file. + TranscriptPath string + + // AgentType is the agent type. Defaults to agent.AgentTypeClaudeCode. + AgentType agent.AgentType +} + +// CreateSessionState writes a session state file to .git/entire-sessions/. +// Returns the session ID used. +func (br *BenchRepo) CreateSessionState(b *testing.B, opts SessionOpts) string { + b.Helper() + + if opts.SessionID == "" { + cpID, err := id.Generate() + if err != nil { + b.Fatalf("generate session ID: %v", err) + } + opts.SessionID = fmt.Sprintf("bench-%s", cpID) + } + if opts.Phase == "" { + opts.Phase = session.PhaseActive + } + + if opts.AgentType == "" { + opts.AgentType = agent.AgentTypeClaudeCode + } + + now := time.Now() + state := &session.State{ + SessionID: opts.SessionID, + BaseCommit: br.HeadHash, + WorktreePath: br.Dir, + WorktreeID: br.WorktreeID, + StartedAt: now, + Phase: opts.Phase, + StepCount: opts.StepCount, + FilesTouched: opts.FilesTouched, + TranscriptPath: opts.TranscriptPath, + AgentType: opts.AgentType, + } + + // Write to .git/entire-sessions/.json + gitDir := filepath.Join(br.Dir, ".git") + sessDir := filepath.Join(gitDir, session.SessionStateDirName) + if err := os.MkdirAll(sessDir, 0o750); err != nil { + b.Fatalf("mkdir sessions: %v", err) + } + + data, err := jsonutil.MarshalIndentWithNewline(state, "", " ") + if err != nil { + b.Fatalf("marshal state: %v", err) + } + + statePath := filepath.Join(sessDir, opts.SessionID+".json") + if err := os.WriteFile(statePath, data, 0o600); err != nil { + b.Fatalf("write state: %v", err) + } + + return opts.SessionID +} + +// TranscriptOpts configures how GenerateTranscript creates JSONL data. +type TranscriptOpts struct { + // MessageCount is the number of JSONL messages to generate. + MessageCount int + + // AvgMessageBytes is the approximate size of each message's content field. + // Defaults to 500. + AvgMessageBytes int + + // IncludeToolUse adds realistic tool_use messages (file edits, bash commands). + IncludeToolUse bool + + // FilesTouched is the list of files to reference in tool_use messages. + // Only used when IncludeToolUse is true. + FilesTouched []string +} + +// GenerateTranscript creates realistic Claude Code JSONL transcript data. +// Returns the raw bytes suitable for writing to full.jsonl. +func GenerateTranscript(opts TranscriptOpts) []byte { + if opts.AvgMessageBytes == 0 { + opts.AvgMessageBytes = 500 + } + + var buf strings.Builder + for i := range opts.MessageCount { + msg := generateTranscriptMessage(i, opts) + data, err := json.Marshal(msg) + if err != nil { + // Should never happen with map[string]any, but satisfy errcheck + continue + } + buf.Write(data) + buf.WriteByte('\n') + } + return []byte(buf.String()) +} + +// WriteTranscriptFile writes transcript data to a file and returns the path. +func (br *BenchRepo) WriteTranscriptFile(b *testing.B, sessionID string, data []byte) string { + b.Helper() + // Write to .entire/metadata//full.jsonl (matching real layout) + relDir := filepath.Join(".entire", "metadata", sessionID) + relPath := filepath.Join(relDir, "full.jsonl") + absDir := filepath.Join(br.Dir, relDir) + if err := os.MkdirAll(absDir, 0o750); err != nil { + b.Fatalf("mkdir transcript dir: %v", err) + } + absPath := filepath.Join(br.Dir, relPath) + if err := os.WriteFile(absPath, data, 0o600); err != nil { + b.Fatalf("write transcript: %v", err) + } + return absPath +} + +// 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 { + name := fmt.Sprintf("src/file_%03d.go", j) + content := GenerateGoFile(i*1000+j, 100) + writeFile(b, br.Dir, name, content) + modified = append(modified, name) + } + + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + metadataDirAbs := filepath.Join(br.Dir, metadataDir) + if err := os.MkdirAll(metadataDirAbs, 0o750); err != nil { + b.Fatalf("mkdir metadata: %v", err) + } + + // Write a minimal transcript to the metadata dir + transcriptPath := filepath.Join(metadataDirAbs, "full.jsonl") + transcript := GenerateTranscript(TranscriptOpts{MessageCount: 5, AvgMessageBytes: 200}) + if err := os.WriteFile(transcriptPath, transcript, 0o600); err != nil { + b.Fatalf("write transcript: %v", err) + } + + _, err := br.Store.WriteTemporary(context.Background(), checkpoint.WriteTemporaryOptions{ + SessionID: sessionID, + BaseCommit: br.HeadHash, + WorktreeID: br.WorktreeID, + ModifiedFiles: modified, + MetadataDir: metadataDir, + MetadataDirAbs: metadataDirAbs, + CommitMessage: fmt.Sprintf("Checkpoint %d", i+1), + AuthorName: "Bench User", + AuthorEmail: "bench@example.com", + IsFirstCheckpoint: i == 0, + }) + if err != nil { + b.Fatalf("write temporary checkpoint %d: %v", i+1, err) + } + } +} + +// SeedMetadataBranch creates N committed checkpoints on the entire/checkpoints/v1 +// branch. This simulates a repository with prior checkpoint history. +func (br *BenchRepo) SeedMetadataBranch(b *testing.B, checkpointCount int) { + b.Helper() + + for i := range checkpointCount { + cpID, err := id.Generate() + if err != nil { + b.Fatalf("generate checkpoint ID: %v", err) + } + sessionID := fmt.Sprintf("seed-session-%04d", i) + transcript := GenerateTranscript(TranscriptOpts{ + MessageCount: 20, + AvgMessageBytes: 300, + }) + + files := make([]string, 0, 5) + for j := range 5 { + files = append(files, fmt.Sprintf("src/file_%03d.go", (i*5+j)%100)) + } + + err = br.Store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ + CheckpointID: cpID, + SessionID: sessionID, + Strategy: br.Strategy, + Transcript: transcript, + Prompts: []string{fmt.Sprintf("Implement feature %d", i)}, + FilesTouched: files, + CheckpointsCount: 3, + AuthorName: "Bench User", + AuthorEmail: "bench@example.com", + Agent: agent.AgentTypeClaudeCode, + }) + if err != nil { + b.Fatalf("write committed checkpoint %d: %v", i+1, err) + } + } +} + +// GenerateGoFile creates a synthetic Go source file with the given number of lines. +// The seed value ensures unique content for each file. +func GenerateGoFile(seed, lines int) string { + var buf strings.Builder + fmt.Fprintf(&buf, "package pkg%d\n\n", seed%100) + + lineNum := 2 + funcNum := 0 + for lineNum < lines { + funcName := fmt.Sprintf("func%d_%d", seed, funcNum) + fmt.Fprintf(&buf, "func %s(ctx context.Context, input string) (string, error) {\n", funcName) + lineNum++ + + bodyLines := min(8, lines-lineNum-1) + for j := range bodyLines { + fmt.Fprintf(&buf, "\tv%d := fmt.Sprintf(\"processing %%s step %d seed %d\", input)\n", j, j, seed) + lineNum++ + } + buf.WriteString("\treturn \"\", nil\n}\n\n") + lineNum += 2 + funcNum++ + } + return buf.String() +} + +// GenerateFileContent creates generic file content of approximately the given byte size. +func GenerateFileContent(seed, sizeBytes int) string { + var buf strings.Builder + line := fmt.Sprintf("// Line content seed=%d ", seed) + padding := strings.Repeat("x", max(1, 80-len(line))) + fullLine := line + padding + "\n" + + for buf.Len() < sizeBytes { + buf.WriteString(fullLine) + } + return buf.String() +} + +//nolint:gosec // G301/G306: benchmark fixtures use standard permissions in temp dirs +func writeFile(b *testing.B, dir, relPath, content string) { + b.Helper() + abs := filepath.Join(dir, relPath) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + b.Fatalf("mkdir %s: %v", filepath.Dir(relPath), err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + b.Fatalf("write %s: %v", relPath, err) + } +} + +//nolint:gosec // G301/G306: benchmark fixtures use standard permissions in temp dirs +func initEntireSettings(b *testing.B, dir, strategy string) { + b.Helper() + entireDir := filepath.Join(dir, ".entire") + if err := os.MkdirAll(filepath.Join(entireDir, "tmp"), 0o755); err != nil { + b.Fatalf("mkdir .entire: %v", err) + } + + settings := map[string]any{ + "strategy": strategy, + "local_dev": true, + } + data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ") + if err != nil { + b.Fatalf("marshal settings: %v", err) + } + if err := os.WriteFile(filepath.Join(entireDir, paths.SettingsFileName), data, 0o644); err != nil { + b.Fatalf("write settings: %v", err) + } +} + +func checkoutBranch(b *testing.B, dir, branch string) { + b.Helper() + c := exec.CommandContext(context.Background(), "git", "checkout", branch) + c.Dir = dir + if output, err := c.CombinedOutput(); err != nil { + b.Fatalf("git checkout %s: %v\n%s", branch, err, output) + } +} + +// generateTranscriptMessage creates a single JSONL message for a Claude Code transcript. +func generateTranscriptMessage(index int, opts TranscriptOpts) map[string]any { + msg := map[string]any{ + "uuid": fmt.Sprintf("msg_%06d", index), + "timestamp": time.Now().Add(time.Duration(index) * time.Second).Format(time.RFC3339), + "parent_uuid": fmt.Sprintf("msg_%06d", max(0, index-1)), + } + + switch { + case opts.IncludeToolUse && index%3 == 2 && len(opts.FilesTouched) > 0: + // Tool use message (every 3rd message) + file := opts.FilesTouched[index%len(opts.FilesTouched)] + msg["type"] = "tool_use" + msg["tool_name"] = "write_to_file" + msg["tool_input"] = map[string]any{ + "path": file, + "content": GenerateFileContent(index, opts.AvgMessageBytes/2), + } + case index%2 == 0: + // Assistant message + msg["type"] = "assistant" + msg["content"] = generatePadding("I'll help you implement this feature. ", opts.AvgMessageBytes) + default: + // Human message + msg["type"] = "human" + msg["content"] = generatePadding("Please update the implementation. ", opts.AvgMessageBytes/3) + } + + return msg +} + +func generatePadding(prefix string, targetBytes int) string { + if len(prefix) >= targetBytes { + return prefix[:targetBytes] + } + padding := strings.Repeat("Lorem ipsum dolor sit amet. ", (targetBytes-len(prefix))/28+1) + result := prefix + padding + if len(result) > targetBytes { + return result[:targetBytes] + } + return result +} diff --git a/cmd/entire/cli/benchutil/benchutil_test.go b/cmd/entire/cli/benchutil/benchutil_test.go new file mode 100644 index 000000000..e9e1208c0 --- /dev/null +++ b/cmd/entire/cli/benchutil/benchutil_test.go @@ -0,0 +1,97 @@ +package benchutil + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/session" +) + +func BenchmarkNewBenchRepo(b *testing.B) { + for b.Loop() { + NewBenchRepo(b, RepoOpts{}) + } +} + +func BenchmarkNewBenchRepo_Large(b *testing.B) { + for b.Loop() { + NewBenchRepo(b, RepoOpts{ + FileCount: 50, + FileSizeLines: 500, + }) + } +} + +func BenchmarkSeedShadowBranch(b *testing.B) { + 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 b.Loop() { + b.StopTimer() + repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) + b.StartTimer() + + repo.SeedMetadataBranch(b, 10) + } +} + +func BenchmarkGenerateTranscript(b *testing.B) { + b.Run("Small_20msg", func(b *testing.B) { + for b.Loop() { + GenerateTranscript(TranscriptOpts{ + MessageCount: 20, + AvgMessageBytes: 500, + }) + } + }) + + b.Run("Medium_200msg", func(b *testing.B) { + for b.Loop() { + GenerateTranscript(TranscriptOpts{ + MessageCount: 200, + AvgMessageBytes: 500, + }) + } + }) + + b.Run("Large_2000msg", func(b *testing.B) { + for b.Loop() { + GenerateTranscript(TranscriptOpts{ + MessageCount: 2000, + AvgMessageBytes: 500, + }) + } + }) + + b.Run("WithToolUse", func(b *testing.B) { + files := []string{"src/main.go", "src/util.go", "src/handler.go"} + for b.Loop() { + GenerateTranscript(TranscriptOpts{ + MessageCount: 200, + AvgMessageBytes: 500, + IncludeToolUse: true, + FilesTouched: files, + }) + } + }) +} + +func BenchmarkCreateSessionState(b *testing.B) { + repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) + + b.ResetTimer() + for b.Loop() { + repo.CreateSessionState(b, SessionOpts{ + Phase: session.PhaseActive, + StepCount: 5, + FilesTouched: []string{"src/file_000.go", "src/file_001.go", "src/file_002.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) + } + } + } +} 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/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) +} diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 1d0a820f5..4a9533b5d 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/mise.toml b/mise.toml index e080e6fdd..904f9e588 100644 --- a/mise.toml +++ b/mise.toml @@ -95,6 +95,18 @@ echo "Checking staged files for duplication..." git diff --cached --name-only -z --diff-filter=ACM | grep -z '\\.go$' | xargs -0 golangci-lint run --enable-only dupl --new=false --max-issues-per-linter=0 --max-same-issues=0 """ +[tasks.bench] +description = "Run all benchmarks" +run = "go test -bench=. -benchmem -run='^$' -timeout=10m ./..." + +[tasks."bench:cpu"] +description = "Run benchmarks with CPU profile" +run = "go test -bench=. -benchmem -run='^$' -cpuprofile=cpu.prof -timeout=10m ./... && echo 'CPU Profiles saved as cpu.prof in each benchmarked package directory. List them with : find. -name cpu.prof -print. View one with: go tool pprof -http=:8080 /path/to/.cpu.prof'" + +[tasks."bench:mem"] +description = "Run benchmarks with memory profile" +run = "go test -bench=. -benchmem -run='^$' -memprofile=mem.prof -timeout=10m ./... && echo 'Memory profiles saved as mem.prof in each benchmarked package directory. List with them: find . -name mem.prof -print. View one with: go tool pprof -http=:8080 /path/to/mem.prof'" + [tasks."test:e2e"] description = "Run E2E tests with real agent calls (requires claude CLI)" # -count=1 disables test caching since E2E tests call real external agents diff --git a/top b/top new file mode 100644 index 000000000..e69de29bb