Skip to content
Merged
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
15 changes: 13 additions & 2 deletions cmd/dispatch/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,15 +242,26 @@ func writeExportFile(dir, id, format, content string) (string, error) {
}

// defaultExportGetDetail loads a full session detail by ID from the default
// session store. It returns (nil, nil) when no session with that ID exists.
// session store. The ID may be a full session ID or a unique short prefix. It
// returns (nil, nil) when no session matches, and an *data.AmbiguousIDPrefixError
// when a short prefix matches more than one session.
func defaultExportGetDetail(id string) (*data.SessionDetail, error) {
store, err := data.Open()
if err != nil {
return nil, fmt.Errorf("opening session store: %w", err)
}
defer store.Close() //nolint:errcheck // read-only, best-effort close

detail, err := store.GetSession(context.Background(), id)
ctx := context.Background()
fullID, err := store.ResolveIDPrefix(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}

detail, err := store.GetSession(ctx, fullID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
Expand Down
7 changes: 6 additions & 1 deletion cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Usage:
Commands:
help Show this help message
version [--json] Print the version
open <id> [--mode M] Resume a session by ID (M: inplace, tab, window, pane)
open <id> [--mode M] Resume a session by ID or prefix (M: inplace, tab, window, pane)
--print writes the resume command instead of launching
open --last [--mode M] Resume the most recently active session
new [dir] [--mode M] Start a new session in a directory (default: current)
Expand All @@ -129,6 +129,11 @@ Commands:
export <id> [flags] Export a session as Markdown or JSON
update Update dispatch to the latest release

Session IDs:
Commands that take <id> (open, export) accept a full session ID or any
unique prefix of one, like a short git SHA. An ambiguous prefix lists the
matching sessions so you can add more characters.

Stats flags:
--json Print the summary as JSON
--calendar Add a per-day activity heatmap
Expand Down
11 changes: 10 additions & 1 deletion cmd/dispatch/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,16 @@ func defaultOpenGetSession(id string) (*data.Session, error) {
}
defer store.Close() //nolint:errcheck // read-only, best-effort close

detail, err := store.GetSession(context.Background(), id)
ctx := context.Background()
fullID, err := store.ResolveIDPrefix(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}

detail, err := store.GetSession(ctx, fullID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
Expand Down
71 changes: 71 additions & 0 deletions internal/data/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,77 @@ func (s *Store) CountSessions(ctx context.Context) (int, error) {
return n, nil
}

// resolveIDPrefixLimit caps how many candidate IDs an ambiguous prefix lists
// so an error message stays readable when a short prefix matches many sessions.
const resolveIDPrefixLimit = 10

// AmbiguousIDPrefixError is returned by ResolveIDPrefix when a session ID
// prefix matches more than one stored session. Candidates lists the matching
// full IDs (capped) so the caller can tell the user how to disambiguate.
type AmbiguousIDPrefixError struct {
Prefix string
Candidates []string
}

func (e *AmbiguousIDPrefixError) Error() string {
return fmt.Sprintf("session ID prefix %q is ambiguous; add more characters to match one of: %s",
e.Prefix, strings.Join(e.Candidates, ", "))
}

// ResolveIDPrefix expands a session ID or a unique ID prefix to a full session
// ID, mirroring git's short-SHA behavior. An exact ID match always wins, even
// when that value is also a prefix of longer IDs. With no exact match the value
// is treated as a prefix: a single match returns that ID, multiple matches
// return an *AmbiguousIDPrefixError listing the candidates, and no match
// returns sql.ErrNoRows. An empty prefix is rejected.
func (s *Store) ResolveIDPrefix(ctx context.Context, prefix string) (string, error) {
if prefix == "" {
return "", errors.New("session ID prefix is empty")
}

// An exact match short-circuits: a full ID beats any prefix collision.
var exact string
err := s.db.QueryRowContext(ctx, "SELECT id FROM sessions WHERE id = ?", prefix).Scan(&exact)
switch {
case err == nil:
return exact, nil
case !errors.Is(err, sql.ErrNoRows):
return "", fmt.Errorf("looking up session %q: %w", prefix, err)
}

// No exact match: resolve as a prefix. Fetch one more than the display cap
// so the candidate list stays bounded even for very short prefixes.
pattern := escapeLIKE(prefix) + "%"
rows, err := s.db.QueryContext(ctx,
`SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\' ORDER BY id LIMIT ?`,
pattern, resolveIDPrefixLimit+1)
if err != nil {
return "", fmt.Errorf("resolving session ID prefix %q: %w", prefix, err)
}
defer closeRows(rows)

var candidates []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return "", fmt.Errorf("scanning session ID: %w", err)
}
candidates = append(candidates, id)
}
if err := rows.Err(); err != nil {
return "", fmt.Errorf("resolving session ID prefix %q: %w", prefix, err)
}

switch len(candidates) {
case 0:
return "", sql.ErrNoRows
case 1:
return candidates[0], nil
default:
return "", &AmbiguousIDPrefixError{Prefix: prefix, Candidates: candidates}
}
}

// GroupSessions groups sessions by the specified pivot field, applying the
// given filter and sort order within each group.
func (s *Store) GroupSessions(ctx context.Context, pivot PivotField, filter FilterOptions, sort SortOptions, limit int) ([]SessionGroup, error) {
Expand Down
158 changes: 158 additions & 0 deletions internal/data/store_prefix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package data

import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"testing"
)

// seedID inserts a bare session with just an ID; the other columns are not
// relevant to prefix resolution.
func seedID(t *testing.T, s *Store, id string) {
t.Helper()
seedSession(t, s.db, id, "", "", "", "", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z")
}

func TestResolveIDPrefix_ExactMatch(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup
seedID(t, s, "abcdef123456")

got, err := s.ResolveIDPrefix(context.Background(), "abcdef123456")
if err != nil {
t.Fatalf("ResolveIDPrefix returned error: %v", err)
}
if got != "abcdef123456" {
t.Errorf("got %q, want %q", got, "abcdef123456")
}
}

func TestResolveIDPrefix_UniquePrefix(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup
seedID(t, s, "abcdef123456")
seedID(t, s, "zzz999")

got, err := s.ResolveIDPrefix(context.Background(), "abc")
if err != nil {
t.Fatalf("ResolveIDPrefix returned error: %v", err)
}
if got != "abcdef123456" {
t.Errorf("got %q, want %q", got, "abcdef123456")
}
}

// TestResolveIDPrefix_ExactWinsOverPrefix verifies git-style behavior: a value
// that is both a full ID and a prefix of longer IDs resolves to the exact ID.
func TestResolveIDPrefix_ExactWinsOverPrefix(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup
seedID(t, s, "abc")
seedID(t, s, "abcdef")

got, err := s.ResolveIDPrefix(context.Background(), "abc")
if err != nil {
t.Fatalf("ResolveIDPrefix returned error: %v", err)
}
if got != "abc" {
t.Errorf("got %q, want exact match %q", got, "abc")
}
}

func TestResolveIDPrefix_Ambiguous(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup
seedID(t, s, "abcdef")
seedID(t, s, "abcxyz")

_, err := s.ResolveIDPrefix(context.Background(), "abc")
if err == nil {
t.Fatal("expected ambiguity error, got nil")
}
var ambErr *AmbiguousIDPrefixError
if !errors.As(err, &ambErr) {
t.Fatalf("expected *AmbiguousIDPrefixError, got %T: %v", err, err)
}
if ambErr.Prefix != "abc" {
t.Errorf("Prefix = %q, want %q", ambErr.Prefix, "abc")
}
if len(ambErr.Candidates) != 2 {
t.Fatalf("Candidates = %v, want 2 entries", ambErr.Candidates)
}
// Candidates are sorted by ID.
if ambErr.Candidates[0] != "abcdef" || ambErr.Candidates[1] != "abcxyz" {
t.Errorf("Candidates = %v, want [abcdef abcxyz]", ambErr.Candidates)
}
// The message should name the prefix and both candidates.
msg := ambErr.Error()
for _, want := range []string{"abc", "abcdef", "abcxyz"} {
if !strings.Contains(msg, want) {
t.Errorf("error message %q should contain %q", msg, want)
}
}
}

func TestResolveIDPrefix_NoMatch(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup
seedID(t, s, "abcdef")

_, err := s.ResolveIDPrefix(context.Background(), "zzz")
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("expected sql.ErrNoRows, got %v", err)
}
}

func TestResolveIDPrefix_Empty(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup

_, err := s.ResolveIDPrefix(context.Background(), "")
if err == nil {
t.Fatal("expected error for empty prefix, got nil")
}
if errors.Is(err, sql.ErrNoRows) {
t.Errorf("empty prefix should not report ErrNoRows, got %v", err)
}
}

// TestResolveIDPrefix_LiteralWildcards verifies that LIKE wildcard characters
// in the prefix are matched literally rather than as patterns.
func TestResolveIDPrefix_LiteralWildcards(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup
seedID(t, s, "a_b123")
seedID(t, s, "axb456")

// "a_" must match only "a_b123"; if the underscore were treated as a LIKE
// wildcard it would also match "axb456" and report an ambiguity.
got, err := s.ResolveIDPrefix(context.Background(), "a_")
if err != nil {
t.Fatalf("ResolveIDPrefix returned error: %v", err)
}
if got != "a_b123" {
t.Errorf("got %q, want %q", got, "a_b123")
}
}

// TestResolveIDPrefix_CandidateCap verifies the ambiguity candidate list is
// bounded so error messages stay readable with many matches.
func TestResolveIDPrefix_CandidateCap(t *testing.T) {
s := newTestStore(t)
defer s.Close() //nolint:errcheck // test cleanup
for i := 0; i < resolveIDPrefixLimit+5; i++ {
seedID(t, s, fmt.Sprintf("z%03d", i))
}

_, err := s.ResolveIDPrefix(context.Background(), "z")
var ambErr *AmbiguousIDPrefixError
if !errors.As(err, &ambErr) {
t.Fatalf("expected *AmbiguousIDPrefixError, got %T: %v", err, err)
}
if len(ambErr.Candidates) != resolveIDPrefixLimit+1 {
t.Errorf("Candidates length = %d, want %d", len(ambErr.Candidates), resolveIDPrefixLimit+1)
}
}