Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
75cf5e0
Add hidden why command shell
pfleidi Apr 27, 2026
7e55643
Merge branch 'main' of github.com:entireio/cli into feat/entire-why
pfleidi Apr 28, 2026
5a81d53
Validate why command path options
pfleidi Apr 28, 2026
4a5bbb3
Add why blame parsing
pfleidi Apr 28, 2026
3dd4318
Add why checkpoint enrichment
pfleidi Apr 28, 2026
8889aa9
Add why static overview rendering
pfleidi Apr 28, 2026
6b90746
Add why source highlighting
pfleidi Apr 28, 2026
96b5cda
Add perf logging to why command
pfleidi Apr 28, 2026
3d1356a
Add detailed why enrichment perf spans
pfleidi Apr 28, 2026
395957b
Add why enrichment loop timing
pfleidi Apr 28, 2026
1994299
Avoid transcript reads in why overview
pfleidi Apr 28, 2026
f37d738
Add interactive why overview TUI
pfleidi Apr 28, 2026
9a65ae8
Compact why TUI blame gutter
pfleidi Apr 28, 2026
d1da345
Show checkpoint agents in why output
pfleidi Apr 29, 2026
9cc60ac
Simplify why view to checkpoint IDs
pfleidi Apr 30, 2026
4a4550e
Align why gutter columns
pfleidi Apr 30, 2026
5790d22
Highlight selected why row
pfleidi Apr 30, 2026
1e1aa57
Fix why TUI highlighted line rendering
pfleidi Apr 30, 2026
7f664e1
Keep why TUI rows within viewport
pfleidi Apr 30, 2026
1aa35b1
Label why TUI gutter columns
pfleidi Apr 30, 2026
f4acdb3
Show why selected-line metadata
pfleidi Apr 30, 2026
7717fe7
Simplify why command implementation
pfleidi Apr 30, 2026
c2f4d92
Format why TUI header metadata
pfleidi May 1, 2026
26506d2
Link why TUI commit hashes
pfleidi May 1, 2026
b4b6691
Merge remote-tracking branch 'origin/main' into feat/entire-why
pfleidi May 1, 2026
9c65319
Group why enrichment perf spans
pfleidi May 1, 2026
cfcd6fa
Use compact git blame porcelain for why
pfleidi May 1, 2026
c87902c
Remove unused v1 session metadata reader
pfleidi May 1, 2026
0702580
Skip why hyperlinks for zero commit hashes
pfleidi May 1, 2026
de14ea6
Remove unused why blame blocks
pfleidi May 1, 2026
166bc49
Avoid eager why highlight fallback rendering
pfleidi May 1, 2026
bd8d4bd
Simplify why command internals
pfleidi May 2, 2026
fc52b87
Merge branch 'main' into feat/entire-why
pfleidi May 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,45 @@ func TestReadSessionContent_ByIndex(t *testing.T) {
}
}

func TestReadSessionMetadataAndPrompts_ReturnsWithoutTranscript(t *testing.T) {
t.Parallel()

repo, _ := setupBranchTestRepo(t)
store := NewGitStore(repo)
checkpointID := id.MustCheckpointID("d1d2d3d4d5d7")
ctx := context.Background()

err := store.WriteCommitted(ctx, WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: "session-meta-only",
Strategy: "manual-commit",
Prompts: []string{"test prompt"},
AuthorName: "Test Author",
AuthorEmail: "[email protected]",
})
if err != nil {
t.Fatalf("WriteCommitted() error = %v", err)
}

if _, err := store.ReadSessionContent(ctx, checkpointID, 0); !errors.Is(err, ErrNoTranscript) {
t.Fatalf("ReadSessionContent() error = %v, want ErrNoTranscript", err)
}

content, err := store.ReadSessionMetadataAndPrompts(ctx, checkpointID, 0)
if err != nil {
t.Fatalf("ReadSessionMetadataAndPrompts() error = %v", err)
}
if content.Metadata.SessionID != "session-meta-only" {
t.Fatalf("session ID = %q, want session-meta-only", content.Metadata.SessionID)
}
if !strings.Contains(content.Prompts, "test prompt") {
t.Fatalf("prompts = %q, want test prompt", content.Prompts)
}
if len(content.Transcript) != 0 {
t.Fatalf("transcript length = %d, want 0", len(content.Transcript))
}
}

// writeSingleSession is a test helper that creates a store with a single session
// and returns the store and checkpoint ID for further testing.
func writeSingleSession(t *testing.T, cpIDStr, sessionID, transcript string) (*GitStore, id.CheckpointID) {
Expand Down
42 changes: 42 additions & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,48 @@ func (s *GitStore) ReadSessionMetadata(ctx context.Context, checkpointID id.Chec
return &metadata, nil
}

// ReadSessionMetadataAndPrompts reads a session's metadata and prompts without loading transcript blobs.
// sessionIndex is 0-based.
func (s *GitStore) ReadSessionMetadataAndPrompts(ctx context.Context, checkpointID id.CheckpointID, sessionIndex int) (*SessionContent, error) {
if err := ctx.Err(); err != nil {
return nil, err //nolint:wrapcheck // Propagating context cancellation
}

ft, err := s.getFetchingTree(ctx)
if err != nil {
return nil, ErrCheckpointNotFound
}

checkpointPath := checkpointID.Path()
checkpointTree, err := ft.Tree(checkpointPath)
if err != nil {
return nil, ErrCheckpointNotFound
}

sessionDir := strconv.Itoa(sessionIndex)
sessionTree, err := checkpointTree.Tree(sessionDir)
if err != nil {
return nil, ErrCheckpointNotFound
}

result := &SessionContent{}
if metadataFile, fileErr := sessionTree.File(paths.MetadataFileName); fileErr == nil {
if content, contentErr := metadataFile.Contents(); contentErr == nil {
if jsonErr := json.Unmarshal([]byte(content), &result.Metadata); jsonErr != nil {
return nil, fmt.Errorf("failed to parse session metadata: %w", jsonErr)
}
}
}

if file, fileErr := sessionTree.File(paths.PromptFileName); fileErr == nil {
if content, contentErr := file.Contents(); contentErr == nil {
result.Prompts = content
}
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReadSessionMetadataAndPrompts() silently ignores missing/failed reads of metadata.json and prompts (File/Contents errors), returning an empty SessionContent. That can mask corruption or unexpected repo state and leads to incorrect downstream agent/summary enrichment. This method should behave more like ReadSessionMetadata(): return a helpful error when metadata.json is missing/unreadable, and consider surfacing prompt read errors too (at least when the file exists but can't be read).

Suggested change
if metadataFile, fileErr := sessionTree.File(paths.MetadataFileName); fileErr == nil {
if content, contentErr := metadataFile.Contents(); contentErr == nil {
if jsonErr := json.Unmarshal([]byte(content), &result.Metadata); jsonErr != nil {
return nil, fmt.Errorf("failed to parse session metadata: %w", jsonErr)
}
}
}
if file, fileErr := sessionTree.File(paths.PromptFileName); fileErr == nil {
if content, contentErr := file.Contents(); contentErr == nil {
result.Prompts = content
}
metadataFile, fileErr := sessionTree.File(paths.MetadataFileName)
if fileErr != nil {
return nil, fmt.Errorf("failed to read session metadata file %q for session %d: %w", paths.MetadataFileName, sessionIndex, fileErr)
}
content, contentErr := metadataFile.Contents()
if contentErr != nil {
return nil, fmt.Errorf("failed to read session metadata contents for session %d: %w", sessionIndex, contentErr)
}
if jsonErr := json.Unmarshal([]byte(content), &result.Metadata); jsonErr != nil {
return nil, fmt.Errorf("failed to parse session metadata: %w", jsonErr)
}
file, fileErr := sessionTree.File(paths.PromptFileName)
if fileErr == nil {
promptContent, promptContentErr := file.Contents()
if promptContentErr != nil {
return nil, fmt.Errorf("failed to read session prompts for session %d: %w", sessionIndex, promptContentErr)
}
result.Prompts = promptContent
} else if !errors.Is(fileErr, object.ErrFileNotFound) {
return nil, fmt.Errorf("failed to read session prompts file %q for session %d: %w", paths.PromptFileName, sessionIndex, fileErr)

Copilot uses AI. Check for mistakes.
}

return result, nil
}

// ReadSessionContent reads the actual content for a specific session within a checkpoint.
// sessionIndex is 0-based (0 for first session, 1 for second, etc.).
// Returns the session's metadata, transcript, prompts, and context.
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func NewRootCmd() *cobra.Command {
cmd.AddCommand(newHooksCmd())
cmd.AddCommand(newVersionCmd())
cmd.AddCommand(newExplainCmd())
cmd.AddCommand(newWhyCmd())
cmd.AddCommand(newDoctorCmd())
cmd.AddCommand(newTraceCmd())
cmd.AddCommand(newTrailCmd())
Expand Down
17 changes: 17 additions & 0 deletions cmd/entire/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ func TestPersistentPostRun_SkipsHiddenParent(t *testing.T) {
}
}

func TestRootCmd_WhyCommandIsHidden(t *testing.T) {
t.Parallel()

root := NewRootCmd()

cmd, _, err := root.Find([]string{"why"})
if err != nil {
t.Fatalf("could not find why command: %v", err)
}
if cmd == nil {
t.Fatal("why command not found")
}
if !cmd.Hidden {
t.Fatal("why command should be hidden while the prototype is in progress")
}
}

func TestPersistentPostRun_ParentHiddenWalk(t *testing.T) {
t.Parallel()

Expand Down
209 changes: 209 additions & 0 deletions cmd/entire/cli/why.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package cli

import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/perf"
"github.com/spf13/cobra"
)

type whyOptions struct {
Path string
}

func newWhyCmd() *cobra.Command {
var opts whyOptions

cmd := &cobra.Command{
Use: "why [path]",
Short: "Explain why a file looks the way it does",
Hidden: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.Path = args[0]
}
if opts.Path != "" && !canRunWhyTUI(cmd.OutOrStdout()) {
cleanup := initWhyLogging(cmd.Context())
defer cleanup()
}
return runWhy(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), opts)
},
}

return cmd
}

func runWhy(ctx context.Context, w io.Writer, _ io.Writer, opts whyOptions) (err error) {
ctx, span := perf.Start(ctx, "why",
slog.String("path", opts.Path),
slog.Bool("has_path", opts.Path != ""))
defer func() {
span.RecordError(err)
span.End()
}()

_, modeSpan := perf.Start(ctx, "detect_mode")
canUseTUI := canRunWhyTUI(w)
modeSpan.End()
if opts.Path == "" {
if !canUseTUI {
return errors.New("path required when not running interactively")
}
return errors.New("interactive file browser is not implemented yet")
}

_, resolveSpan := perf.Start(ctx, "resolve_path")
repoRoot, gitPath, _, err := resolveWhyPath(ctx, opts.Path)
if err != nil {
resolveSpan.RecordError(err)
resolveSpan.End()
return err
}
resolveSpan.End()

data, err := loadWhyViewData(ctx, repoRoot, gitPath)
if err != nil {
return err
}

if canUseTUI {
_, renderSpan := perf.Start(ctx, "render_tui")
if err := runWhyTUI(ctx, w, data); err != nil {
renderSpan.RecordError(err)
renderSpan.End()
return err
}
renderSpan.End()
return nil
}

_, renderSpan := perf.Start(ctx, "render_static")
content := renderWhyStatic(data)
renderSpan.End()

_, outputSpan := perf.Start(ctx, "write_output")
outputExplainContent(w, content, false)
outputSpan.End()
return nil
}

var canRunWhyTUI = defaultCanRunWhyTUI

func defaultCanRunWhyTUI(w io.Writer) bool {
return !IsAccessibleMode() && interactive.IsTerminalWriter(w) && interactive.CanPromptInteractively()
}

func initWhyLogging(ctx context.Context) func() {
if _, err := paths.WorktreeRoot(ctx); err != nil {
return func() {}
}
logging.SetLogLevelGetter(GetLogLevel)
if err := logging.Init(ctx, ""); err != nil {
return func() {}
}
return logging.Close
}

func resolveWhyPath(ctx context.Context, input string) (string, string, string, error) {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return "", "", "", fmt.Errorf("not a git repository: %w", err)
}
repoRoot = normalizeWhyPathForRel(repoRoot)

absPath := input
if !filepath.IsAbs(absPath) {
absPath, err = filepath.Abs(absPath)
if err != nil {
return "", "", "", fmt.Errorf("resolving path %q: %w", input, err)
}
}
absPath = normalizeWhyPathForRel(absPath)

relPath, err := filepath.Rel(repoRoot, absPath)
if err != nil {
return "", "", "", fmt.Errorf("resolving path %q relative to repository: %w", input, err)
}
if relPath == "." || strings.HasPrefix(relPath, ".."+string(filepath.Separator)) || relPath == ".." {
return "", "", "", fmt.Errorf("path %q is outside the repository", input)
}

return repoRoot, filepath.ToSlash(relPath), absPath, nil
}

func normalizeWhyPathForRel(path string) string {
cleaned := filepath.Clean(path)
if resolved, err := filepath.EvalSymlinks(cleaned); err == nil {
return resolved
}
dir := filepath.Dir(cleaned)
base := filepath.Base(cleaned)
if resolvedDir, err := filepath.EvalSymlinks(dir); err == nil {
return filepath.Join(resolvedDir, base)
}
return cleaned
}

func loadWhyViewData(ctx context.Context, repoRoot, gitPath string) (whyViewData, error) {
_, blameSpan := perf.Start(ctx, "git_blame")
blameOutput, err := runGitBlame(ctx, repoRoot, gitPath)
if err != nil {
blameSpan.RecordError(err)
blameSpan.End()
return whyViewData{}, err
}
blameSpan.End()

_, parseSpan := perf.Start(ctx, "parse_blame")
lines, err := parseBlamePorcelain(blameOutput)
if err != nil {
parseSpan.RecordError(err)
parseSpan.End()
return whyViewData{}, fmt.Errorf("parse git blame output: %w", err)
}
parseSpan.End()

_, buildRowsSpan := perf.Start(ctx, "build_rows")
blocks := collapseWhyBlameBlocks(lines)
rows := buildWhyBlameRows(lines, blocks)
buildRowsSpan.End()

_, openRepoSpan := perf.Start(ctx, "open_repository")
repo, err := openRepository(ctx)
if err != nil {
openRepoSpan.RecordError(err)
openRepoSpan.End()
return whyViewData{}, fmt.Errorf("open repository: %w", err)
}
openRepoSpan.End()

_, lookupSpan := perf.Start(ctx, "init_checkpoint_lookup")
lookup, err := newWhyCheckpointLookup(ctx, repo)
if err != nil {
lookupSpan.RecordError(err)
lookupSpan.End()
return whyViewData{}, fmt.Errorf("initialize checkpoint lookup: %w", err)
}
lookupSpan.End()

_, enrichSpan := perf.Start(ctx, "enrich_commits")
commits := enrichWhyCommits(ctx, repo, lookup, blocks)
enrichSpan.End()

return whyViewData{
GitPath: gitPath,
Rows: rows,
Blocks: blocks,
Commits: commits,
}, nil
}
Loading
Loading