Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion cmd/entire/cli/checkpoint_group.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func newCheckpointSearchCmd() *cobra.Command {
cmd.Hidden = false
// newSearchCmd's examples use the `entire search` prefix for that top-level
// alias; under the canonical `checkpoint` group they must match this path.
cmd.Example = " entire checkpoint search \"retry backoff\" --json\n entire checkpoint search \"auth timeout author:alice date:week\"\n entire checkpoint search --code \"parseToken\""
cmd.Example = " entire checkpoint search \"retry backoff\" --json\n entire checkpoint search \"retry backoff\" --json --compact\n entire checkpoint search \"auth timeout author:alice date:week\"\n entire checkpoint search --code \"parseToken\""
return cmd
}

Expand Down
83 changes: 83 additions & 0 deletions cmd/entire/cli/search/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,86 @@ func TestResultID_RawDataFallback(t *testing.T) {
t.Errorf("checkpoint ResultID() = %q, want \"ck1\"", got)
}
}

// TestResultAccessors_RawDataFallback verifies repo/pr rows expose identifying
// fields from the raw payload, so trimmed views (e.g. --compact) don't collapse
// them to just {id, type, score}.
func TestResultAccessors_RawDataFallback(t *testing.T) {
t.Parallel()

const repoName = "backend"

var repoRow Result
if err := json.Unmarshal([]byte(`{"type":"repo","data":{"id":"01JREPO","name":"backend","org":"acme","createdAt":"2026-01-02T00:00:00Z"},"searchMeta":{"score":1}}`), &repoRow); err != nil {
t.Fatal(err)
}
if got := repoRow.ResultTitle(); got != repoName {
t.Errorf("repo ResultTitle() = %q, want \"backend\"", got)
}
if got := repoRow.ResultRepo(); got != repoName {
t.Errorf("repo ResultRepo() = %q, want \"backend\"", got)
}
if got := repoRow.ResultOrg(); got != "acme" {
t.Errorf("repo ResultOrg() = %q, want \"acme\"", got)
}
if got := repoRow.ResultCreatedAt(); got != "2026-01-02T00:00:00Z" {
t.Errorf("repo ResultCreatedAt() = %q", got)
}

// A row carrying only an owner-qualified fullName splits into org + bare
// repo, so org+"/"+repo joins never double the owner (acme/acme/backend).
var qualifiedRow Result
if err := json.Unmarshal([]byte(`{"type":"repo","data":{"id":"01JQUAL","fullName":"acme/backend"},"searchMeta":{"score":1}}`), &qualifiedRow); err != nil {
t.Fatal(err)
}
if got := qualifiedRow.ResultOrg(); got != "acme" {
t.Errorf("fullName-only ResultOrg() = %q, want \"acme\"", got)
}
if got := qualifiedRow.ResultRepo(); got != repoName {
t.Errorf("fullName-only ResultRepo() = %q, want bare \"backend\"", got)
}

var prRow Result
if err := json.Unmarshal([]byte(`{"type":"pr","data":{"id":"pr-9","title":"Fix login retry","repo":"backend","userLogin":"alice","headBranch":"fix/login"},"searchMeta":{"score":1}}`), &prRow); err != nil {
t.Fatal(err)
}
if got := prRow.ResultTitle(); got != "Fix login retry" {
t.Errorf("pr ResultTitle() = %q, want \"Fix login retry\"", got)
}
if got := prRow.ResultRepo(); got != repoName {
t.Errorf("pr ResultRepo() = %q, want \"backend\"", got)
}
if got := prRow.ResultAuthor(); got != testAuthor {
t.Errorf("pr ResultAuthor() = %q, want \"alice\"", got)
}
if got := prRow.ResultBranch(); got != "fix/login" {
t.Errorf("pr ResultBranch() = %q, want \"fix/login\"", got)
}
// Fields absent from the payload stay empty.
if got := prRow.ResultCreatedAt() + prRow.ResultOrg(); got != "" {
t.Errorf("pr accessors for absent fields = %q, want all empty", got)
}
}

// TestResultAccessors_TypedRowsNeverReadRawPayload pins the gate: for typed
// rows (checkpoint/commit/session) an empty typed field stays empty even when
// the raw payload carries a same-named key — the raw fallback is reserved for
// types without a typed struct, so backend field additions can't silently
// change what the TUI or compact output renders.
func TestResultAccessors_TypedRowsNeverReadRawPayload(t *testing.T) {
t.Parallel()

var sessionRow Result
if err := json.Unmarshal([]byte(`{"type":"session","data":{"sessionId":"s1","displayName":"","author":"alice@example.com","title":"stray","branch":null},"searchMeta":{"score":1}}`), &sessionRow); err != nil {
t.Fatal(err)
}
if got := sessionRow.ResultAuthor(); got != "" {
t.Errorf("session ResultAuthor() = %q, want \"\" (raw author must be suppressed)", got)
}
if got := sessionRow.ResultTitle(); got != "" {
t.Errorf("session ResultTitle() = %q, want \"\" (raw title must be suppressed)", got)
}
if got := sessionRow.ResultBranch(); got != "" {
t.Errorf("session ResultBranch() = %q, want \"\"", got)
}
}
128 changes: 101 additions & 27 deletions cmd/entire/cli/search/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,12 @@ type Result struct {
Commit *CommitResult `json:"-"`
Session *SessionResult `json:"-"`

// rawData preserves the original JSON for unknown types (repo, pr)
// rawData preserves the original JSON for unknown types (repo, pr).
rawData json.RawMessage
// rawFields is rawData decoded once at unmarshal time, and only for types
// without a typed struct (repo, pr) — accessors never read raw fields for
// typed rows, so a backend field addition cannot change what they return.
rawFields map[string]json.RawMessage
}

// resultJSON is the wire format for JSON marshaling/unmarshaling.
Expand Down Expand Up @@ -173,6 +177,7 @@ func (r *Result) UnmarshalJSON(b []byte) error {
// Clear any previously-decoded payloads so a reused Result keeps the
// "exactly one typed pointer is non-nil" invariant.
r.Checkpoint, r.Commit, r.Session = nil, nil, nil
r.rawFields = nil

switch raw.Type {
case TypeCheckpoint:
Expand All @@ -193,6 +198,11 @@ func (r *Result) UnmarshalJSON(b []byte) error {
return fmt.Errorf("unmarshaling session data: %w", err)
}
r.Session = &d
default:
// Unknown types (repo, pr): decode the payload once so accessors can
// read identifying fields without re-parsing per call (the TUI calls
// them per row per render).
_ = json.Unmarshal(raw.Data, &r.rawFields) //nolint:errcheck // best-effort; accessors return "" when nil
}
return nil
}
Expand All @@ -217,46 +227,94 @@ func resultField(r *Result, fromCheckpoint func(*CheckpointResult) string, fromC
return ""
}

// ResultOrg returns the org for any result type.
// rawString returns the first non-empty string value among the given keys in
// the raw payload of a result without a typed struct (repo, pr). Returns ""
// for typed results — rawFields is only populated for unknown types — or when
// no key matches.
func (r *Result) rawString(keys ...string) string {
for _, k := range keys {
var s string
if err := json.Unmarshal(r.rawFields[k], &s); err == nil && s != "" {
return s
}
}
return ""
}

// ResultOrg returns the org for any result type. Repo/PR raw payloads may
// only carry an owner-qualified "fullName"; its owner segment is the org.
func (r *Result) ResultOrg() string {
return resultField(r,
if v := resultField(r,
func(c *CheckpointResult) string { return c.Org },
func(c *CommitResult) string { return c.Org },
func(s *SessionResult) string { return s.Org })
func(s *SessionResult) string { return s.Org }); v != "" {
return v
}
if v := r.rawString("org"); v != "" {
return v
}
owner, _ := splitFullName(r.rawString("fullName"))
return owner
}

// ResultRepo returns the repo for any result type.
// ResultRepo returns the bare repo name (no owner) for any result type, so
// callers can join it with ResultOrg without doubling the owner. Repo/PR raw
// payloads carry it under "repo" or "name", or qualified inside "fullName".
func (r *Result) ResultRepo() string {
return resultField(r,
if v := resultField(r,
func(c *CheckpointResult) string { return c.Repo },
func(c *CommitResult) string { return c.Repo },
func(s *SessionResult) string { return s.Repo })
func(s *SessionResult) string { return s.Repo }); v != "" {
return v
}
if v := r.rawString("repo", "name"); v != "" {
return v
}
_, name := splitFullName(r.rawString("fullName"))
return name
}

// splitFullName splits an "owner/repo" full name; without a slash the whole
// value is the repo name.
func splitFullName(fullName string) (owner, name string) {
if i := strings.IndexByte(fullName, '/'); i >= 0 {
return fullName[:i], fullName[i+1:]
}
return "", fullName
}

// ResultBranch returns the branch for any result type.
// ResultBranch returns the branch for any result type. PR raw payloads carry
// the head branch under "headBranch" (searcher.PRResult).
func (r *Result) ResultBranch() string {
return resultField(r,
if v := resultField(r,
func(c *CheckpointResult) string { return c.Branch },
func(c *CommitResult) string { return c.Branch },
func(s *SessionResult) string {
if s.Branch != nil {
return *s.Branch
}
return ""
})
}); v != "" {
return v
}
return r.rawString("headBranch")
}

// ResultCreatedAt returns the createdAt for any result type.
func (r *Result) ResultCreatedAt() string {
return resultField(r,
if v := resultField(r,
func(c *CheckpointResult) string { return c.CreatedAt },
func(c *CommitResult) string { return c.CreatedAt },
func(s *SessionResult) string { return s.CreatedAt })
func(s *SessionResult) string { return s.CreatedAt }); v != "" {
return v
}
return r.rawString("createdAt")
}

// ResultAuthor returns the display author for any result type.
// ResultAuthor returns the display author for any result type. PR raw payloads
// carry the author login under "userLogin" (searcher.PRResult).
func (r *Result) ResultAuthor() string {
return resultField(r,
if v := resultField(r,
func(c *CheckpointResult) string {
if c.AuthorUsername != nil && *c.AuthorUsername != "" {
return *c.AuthorUsername
Expand All @@ -274,7 +332,10 @@ func (r *Result) ResultAuthor() string {
return *s.AuthorUsername
}
return ""
})
}); v != "" {
return v
}
return r.rawString("userLogin")
}

// ResultID returns the primary ID for any result type. Types without a typed
Expand All @@ -288,20 +349,13 @@ func (r *Result) ResultID() string {
func(s *SessionResult) string { return s.SessionID }); id != "" {
return id
}
if len(r.rawData) > 0 {
var d struct {
ID string `json:"id"`
}
if err := json.Unmarshal(r.rawData, &d); err == nil {
return d.ID
}
}
return ""
return r.rawString("id")
}

// ResultTitle returns the primary display text for any result type.
// ResultTitle returns the primary display text for any result type. Repo/PR
// raw payloads identify themselves via "title", "name", or "fullName".
func (r *Result) ResultTitle() string {
return resultField(r,
if v := resultField(r,
func(c *CheckpointResult) string {
// Prefer the commit title over the prompt; fall back to the prompt
// for uncommitted checkpoints. The full prompt remains in the detail view.
Expand All @@ -319,7 +373,27 @@ func (r *Result) ResultTitle() string {
}
return c.CommitMessage
},
func(s *SessionResult) string { return s.DisplayName })
func(s *SessionResult) string { return s.DisplayName }); v != "" {
return v
}
return r.rawString("title", "name", "fullName")
}

// ResultDescription returns the repo description for raw-payload rows
// (searcher.RepoResult). Typed rows return "" — rawFields is never populated
// for them.
func (r *Result) ResultDescription() string {
return r.rawString("description")
}

// ResultCheckpointCount returns the indexed checkpoint count for raw-payload
// repo rows (searcher.RepoResult), 0 elsewhere.
func (r *Result) ResultCheckpointCount() int {
var n int
if err := json.Unmarshal(r.rawFields["checkpointCount"], &n); err != nil {
return 0
}
return n
}

// TypeCounts holds per-type result counts.
Expand Down
Loading
Loading