From 2b877f31f24b70c858018e08d10ed1956af0bd7f Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 18 Feb 2026 22:30:46 -0800 Subject: [PATCH 01/12] create bench util package --- CONTRIBUTING.md | 4 +- cmd/entire/cli/benchutil/benchutil.go | 550 +++++++++++++++++++++ cmd/entire/cli/benchutil/benchutil_test.go | 97 ++++ mise.toml | 12 + 4 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 cmd/entire/cli/benchutil/benchutil.go create mode 100644 cmd/entire/cli/benchutil/benchutil_test.go 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/benchutil/benchutil.go b/cmd/entire/cli/benchutil/benchutil.go new file mode 100644 index 000000000..a382fa84c --- /dev/null +++ b/cmd/entire/cli/benchutil/benchutil.go @@ -0,0 +1,550 @@ +// 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 +} + +// 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(), + } + + // 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. +func (br *BenchRepo) SeedShadowBranch(b *testing.B, sessionID string, checkpointCount int, filesPerCheckpoint int) { + b.Helper() + + 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: "manual-commit", + 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..0c9ee1797 --- /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) { + repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) + sessionID := repo.CreateSessionState(b, SessionOpts{}) + + b.ResetTimer() + for b.Loop() { + // Each iteration seeds a fresh shadow branch + // (will append to existing, but that's fine for benchmarking) + repo.SeedShadowBranch(b, sessionID, 5, 3) + } +} + +func BenchmarkSeedMetadataBranch(b *testing.B) { + repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) + + b.ResetTimer() + for b.Loop() { + 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/mise.toml b/mise.toml index e080e6fdd..7f9324d37 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 'Profile saved to cpu.prof. View with: go tool pprof -http=:8080 cpu.prof'" + +[tasks."bench:mem"] +description = "Run benchmarks with memory profile" +run = "go test -bench=. -benchmem -run='^$' -memprofile=mem.prof -timeout=10m ./... && echo 'Profile saved to mem.prof. View with: go tool pprof -http=:8080 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 From 4d5af0a843b4457412c5857b30240ad8daa152f8 Mon Sep 17 00:00:00 2001 From: evisdren Date: Wed, 18 Feb 2026 22:45:37 -0800 Subject: [PATCH 02/12] 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 fa55b74f22b4487550cf2efa91bb2dbb592fedd3 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 12:15:40 -0800 Subject: [PATCH 03/12] 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 deb2f45f8d03658b246ce0cfac98d88c52821a3b Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 12:52:25 -0800 Subject: [PATCH 04/12] 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 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/top b/top new file mode 100644 index 000000000..e69de29bb From 1a95bc37c9d2d883364169925985ec30d3c42184 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 13:04:12 -0800 Subject: [PATCH 05/12] 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 b95a0d5b7b3e6829d2ab7d69e52aff3571b87b04 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 13:56:24 -0800 Subject: [PATCH 06/12] update run instrucs and seedshadowbranch --- cmd/entire/cli/benchutil/benchutil.go | 14 +++++++++++++- cmd/entire/cli/benchutil/benchutil_test.go | 18 +++++++++--------- mise.toml | 4 ++-- 3 files changed, 24 insertions(+), 12 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 0c9ee1797..e9e1208c0 100644 --- a/cmd/entire/cli/benchutil/benchutil_test.go +++ b/cmd/entire/cli/benchutil/benchutil_test.go @@ -22,22 +22,22 @@ func BenchmarkNewBenchRepo_Large(b *testing.B) { } func BenchmarkSeedShadowBranch(b *testing.B) { - repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) - sessionID := repo.CreateSessionState(b, SessionOpts{}) - - b.ResetTimer() for b.Loop() { - // Each iteration seeds a fresh shadow branch - // (will append to existing, but that's fine for benchmarking) + 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) { - repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) - - b.ResetTimer() for b.Loop() { + b.StopTimer() + repo := NewBenchRepo(b, RepoOpts{FileCount: 10}) + b.StartTimer() + repo.SeedMetadataBranch(b, 10) } } diff --git a/mise.toml b/mise.toml index 7f9324d37..904f9e588 100644 --- a/mise.toml +++ b/mise.toml @@ -101,11 +101,11 @@ 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 'Profile saved to cpu.prof. View with: go tool pprof -http=:8080 cpu.prof'" +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 'Profile saved to mem.prof. View with: go tool pprof -http=:8080 mem.prof'" +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)" From f413d34be360402257b66341f7c371f1e73e1b10 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 16:59:14 -0800 Subject: [PATCH 07/12] replace git shell out with pure go --- cmd/entire/cli/bench_test.go | 40 ++++++--- cmd/entire/cli/status.go | 47 +++++++++-- cmd/entire/cli/status_test.go | 154 ++++++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 16 deletions(-) diff --git a/cmd/entire/cli/bench_test.go b/cmd/entire/cli/bench_test.go index 0039a8bca..4558c135e 100644 --- a/cmd/entire/cli/bench_test.go +++ b/cmd/entire/cli/bench_test.go @@ -29,16 +29,31 @@ and then go to http://localhost:8089/ui/flamegraph // // 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)) + 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)) } -func benchStatus(sessionCount int, detailed bool) func(*testing.B) { +// 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{}) @@ -54,10 +69,13 @@ func benchStatus(sessionCount int, detailed bool) func(*testing.B) { 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. + // Always clear RepoRoot to simulate a fresh CLI invocation. paths.ClearRepoRootCache() - session.ClearGitCommonDirCache() + + 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/status.go b/cmd/entire/cli/status.go index a81639451..0d9876e04 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -7,7 +7,7 @@ import ( "io" "io/fs" "os" - "os/exec" + "path/filepath" "sort" "strings" "time" @@ -286,12 +286,49 @@ 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 "" } - return strings.TrimSpace(string(output)) + + 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 "" + } + + ref := strings.TrimSpace(string(data)) + + // Symbolic ref: "ref: refs/heads/" + if strings.HasPrefix(ref, "ref: refs/heads/") { + return strings.TrimPrefix(ref, "ref: refs/heads/") + } + + // Detached HEAD or other ref type + return "HEAD" } diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index a54dfb983..70874e6b9 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -3,14 +3,168 @@ 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) + } + + // New repo on default branch — HEAD is "ref: refs/heads/master" + branch := resolveWorktreeBranch(dir) + if branch != "master" { //nolint:goconst // go-git default branch name + t.Errorf("resolveWorktreeBranch() = %q, want %q", branch, "master") + } +} + +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 TestRunStatus_Enabled(t *testing.T) { setupTestRepo(t) writeSettings(t, testSettingsEnabled) From e9130643f0ff952ab855fdf04aa210c01f221e0b Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 17:05:03 -0800 Subject: [PATCH 08/12] fix lint --- cmd/entire/cli/status_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 70874e6b9..e6ebdbc03 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -29,8 +29,9 @@ func TestResolveWorktreeBranch_RegularRepo(t *testing.T) { // New repo on default branch — HEAD is "ref: refs/heads/master" branch := resolveWorktreeBranch(dir) - if branch != "master" { //nolint:goconst // go-git default branch name - t.Errorf("resolveWorktreeBranch() = %q, want %q", branch, "master") + wantBranch := "master" + if branch != wantBranch { + t.Errorf("resolveWorktreeBranch() = %q, want %q", branch, wantBranch) } } From 571a52b497a8460fc88a69fb911e8dec311aec6e Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 17:11:14 -0800 Subject: [PATCH 09/12] linter again --- cmd/entire/cli/status_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index e6ebdbc03..e4f714d20 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -27,9 +27,14 @@ func TestResolveWorktreeBranch_RegularRepo(t *testing.T) { t.Fatalf("git init: %v", err) } - // New repo on default branch — HEAD is "ref: refs/heads/master" + // 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) - wantBranch := "master" if branch != wantBranch { t.Errorf("resolveWorktreeBranch() = %q, want %q", branch, wantBranch) } From 89cd36ca632ff7b2a7694797839a56004626c387 Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 21:52:43 -0800 Subject: [PATCH 10/12] format bench output --- cpu.prof | Bin 0 -> 19329 bytes mise.toml | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 cpu.prof diff --git a/cpu.prof b/cpu.prof new file mode 100644 index 0000000000000000000000000000000000000000..07340a6de8d1b92fa5bdd582a14e07f3b87a78a2 GIT binary patch literal 19329 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 A&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 NS/OP B/OP ALLOCS/OP" + echo "--------- ----- ----- ---- ---------" + # Strip unit suffixes — headers already label each column + echo "$bench_lines" | sed 's/ ns\/op//g; s/ B\/op//g; s/ allocs\/op//g; s/ transcript_bytes//g' + } | column -t +fi +''' [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'" +description = "Run benchmarks with CPU profile (pass package as arg, default: ./cmd/entire/cli/)" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +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 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' + } | 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" -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'" +description = "Run benchmarks with memory profile (pass package as arg, default: ./cmd/entire/cli/)" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +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 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' + } | column -t +fi + +echo "" +echo "Memory profile saved to mem.prof. View with: go tool pprof -http=:8080 mem.prof" +''' [tasks."test:e2e"] description = "Run E2E tests with real agent calls (requires claude CLI)" From 431be7c2c0b09fe6cb4962420df8f497287783bd Mon Sep 17 00:00:00 2001 From: evisdren Date: Thu, 19 Feb 2026 22:04:34 -0800 Subject: [PATCH 11/12] add ms to benchmark view --- cpu.prof | Bin 19329 -> 20926 bytes mise.toml | 23 +++++++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/cpu.prof b/cpu.prof index 07340a6de8d1b92fa5bdd582a14e07f3b87a78a2..b5d77d950b23722a5a35332d72a06f50cfe1e036 100644 GIT binary patch literal 20926 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%0DAXil^cs-bh=pvashqt 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 A&2 bench_lines=$(echo "$output" | grep '^Benchmark' || true) if [ -n "$bench_lines" ]; then { - echo "BENCHMARK ITERS NS/OP B/OP ALLOCS/OP" - echo "--------- ----- ----- ---- ---------" - # Strip unit suffixes — headers already label each column - echo "$bench_lines" | sed 's/ ns\/op//g; s/ B\/op//g; s/ allocs\/op//g; s/ transcript_bytes//g' + 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 ''' @@ -132,9 +133,10 @@ echo "$output" | grep -v '^Benchmark' >&2 bench_lines=$(echo "$output" | grep '^Benchmark' || true) if [ -n "$bench_lines" ]; then { - echo "BENCHMARK ITERS 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' + 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 @@ -156,9 +158,10 @@ echo "$output" | grep -v '^Benchmark' >&2 bench_lines=$(echo "$output" | grep '^Benchmark' || true) if [ -n "$bench_lines" ]; then { - echo "BENCHMARK ITERS 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' + 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 From 00022cd3ca73341d937db191d6def724fb2552bb Mon Sep 17 00:00:00 2001 From: evisdren Date: Fri, 20 Feb 2026 09:42:48 -0800 Subject: [PATCH 12/12] add support for git reftables and a test --- cmd/entire/cli/status.go | 33 ++++++++++++++++++++++++++++++--- cmd/entire/cli/status_test.go | 20 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index 0d9876e04..c878cb208 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -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) { @@ -326,9 +330,32 @@ func resolveWorktreeBranch(worktreePath string) string { // Symbolic ref: "ref: refs/heads/" if strings.HasPrefix(ref, "ref: refs/heads/") { - return strings.TrimPrefix(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 "HEAD" + 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 e4f714d20..4583838a1 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -171,6 +171,26 @@ func TestResolveWorktreeBranch_NotARepo(t *testing.T) { } } +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)