From c0a4455cf2a798357950659146686fa202894aa0 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:33:13 -0700 Subject: [PATCH] feat(open,export): resolve short session-ID prefixes Accept a unique prefix of a session ID wherever open and export take a full , mirroring git short-SHA behavior. An exact full-ID match wins outright; a unique prefix expands to its full ID; an ambiguous prefix is rejected with the list of matching sessions. Resolution lives in a new Store.ResolveIDPrefix and is wired into the default DB loaders only, so the command test seams are unaffected. LIKE wildcards in the prefix are escaped and matched literally. The usage banner documents the new behavior. Closes #314 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/dispatch/export.go | 15 ++- cmd/dispatch/main.go | 7 +- cmd/dispatch/main_test.go | 16 ++- cmd/dispatch/open.go | 11 +- internal/data/store.go | 71 +++++++++++++ internal/data/store_prefix_test.go | 158 +++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 internal/data/store_prefix_test.go diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go index f9453ef..deb6ac3 100644 --- a/cmd/dispatch/export.go +++ b/cmd/dispatch/export.go @@ -188,7 +188,9 @@ 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 { @@ -196,7 +198,16 @@ func defaultExportGetDetail(id string) (*data.SessionDetail, 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 diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e6833e7..5a7d22f 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -115,7 +115,7 @@ Usage: Commands: help Show this help message version Print the version - open [--mode M] Resume a session by ID (M: inplace, tab, window, pane) + open [--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) @@ -129,6 +129,11 @@ Commands: export [flags] Export a session as Markdown or JSON update Update dispatch to the latest release +Session IDs: + Commands that take (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 diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 08e19a2..d5b0159 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -457,15 +457,23 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + // Drain the pipe concurrently. On Windows the OS pipe buffer is small + // (~4KB), so a usage banner larger than that would block printUsage on + // write if we only read after it returns. Reading on a goroutine keeps + // the writer unblocked regardless of banner size. + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + printUsage() w.Close() os.Stdout = origStdout - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - - output := buf.String() + output := <-done for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { if !strings.Contains(output, want) { t.Errorf("printUsage() should mention %q, got:\n%s", want, output) diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index 7611339..6030f33 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -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 diff --git a/internal/data/store.go b/internal/data/store.go index a320712..7732a32 100644 --- a/internal/data/store.go +++ b/internal/data/store.go @@ -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) { diff --git a/internal/data/store_prefix_test.go b/internal/data/store_prefix_test.go new file mode 100644 index 0000000..5cdef33 --- /dev/null +++ b/internal/data/store_prefix_test.go @@ -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) + } +}