From 7e993540ecfeb6cdb6be3024fc8bdeaf74986f7b Mon Sep 17 00:00:00 2001 From: Byron Guina Date: Wed, 23 Sep 2026 21:50:52 -0500 Subject: [PATCH] fix(project): trim whitespace from project names Project names were stored verbatim, so -p " spindle" (e.g. from an unsplit shell variable) created a distinct, visually identical project that -p spindle never matched. Names are now trimmed on every write path (CLI -p, prog project, CreateItem, SetProject, EnsureProject), and schema migration v4 trims existing rows in items, learnings, labels, concepts, and projects, merging padded project rows into the trimmed name. Co-Authored-By: Claude Opus 5.5 --- cmd/prog/main.go | 9 +++- internal/db/db.go | 23 +++++++++- internal/db/items.go | 4 ++ internal/db/projects.go | 3 ++ internal/db/projects_test.go | 81 ++++++++++++++++++++++++++++++++++++ internal/model/item.go | 11 +++++ 6 files changed, 128 insertions(+), 3 deletions(-) diff --git a/cmd/prog/main.go b/cmd/prog/main.go index c6cdc2a..81cf93b 100644 --- a/cmd/prog/main.go +++ b/cmd/prog/main.go @@ -112,6 +112,10 @@ Quick start: prog ready -p myproject prog start prog done `, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Every filter compares project names exactly, so normalize -p once here. + flagProject = model.NormalizeProject(flagProject) + }, } var initCmd = &cobra.Command{ @@ -1078,10 +1082,11 @@ Example: } defer func() { _ = database.Close() }() - if err := database.SetProject(args[0], args[1]); err != nil { + project := model.NormalizeProject(args[1]) + if err := database.SetProject(args[0], project); err != nil { return err } - fmt.Printf("%s is now in project %s\n", args[0], args[1]) + fmt.Printf("%s is now in project %s\n", args[0], project) return nil }, } diff --git a/internal/db/db.go b/internal/db/db.go index 1ec8dd9..094d8d0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -15,7 +15,7 @@ import ( // SchemaVersion is the current schema version. // Increment this when adding new migrations. -const SchemaVersion = 3 +const SchemaVersion = 4 // baseSchema is the original schema (version 1). // New tables should be added via migrations, not here. @@ -143,6 +143,27 @@ CREATE INDEX IF NOT EXISTS idx_item_labels_label ON item_labels(label_id); // Version 3: Add definition_of_done to items ` ALTER TABLE items ADD COLUMN definition_of_done TEXT; +`, + // Version 4: Trim whitespace from project names. Earlier versions stored -p + // verbatim, so " foo" became a separate project that "-p foo" never matched. + // Trims the same characters as model.NormalizeProject. Labels and concepts + // use OR IGNORE: a row whose trimmed (name, project) already exists is left + // as-is rather than failing the migration. + ` +UPDATE items SET project = TRIM(project, ' ' || char(9, 10, 11, 12, 13)) + WHERE project != TRIM(project, ' ' || char(9, 10, 11, 12, 13)); +UPDATE learnings SET project = TRIM(project, ' ' || char(9, 10, 11, 12, 13)) + WHERE project != TRIM(project, ' ' || char(9, 10, 11, 12, 13)); +UPDATE OR IGNORE labels SET project = TRIM(project, ' ' || char(9, 10, 11, 12, 13)) + WHERE project != TRIM(project, ' ' || char(9, 10, 11, 12, 13)); +UPDATE OR IGNORE concepts SET project = TRIM(project, ' ' || char(9, 10, 11, 12, 13)) + WHERE project != TRIM(project, ' ' || char(9, 10, 11, 12, 13)); +INSERT OR IGNORE INTO projects (name, description, created_at, updated_at) + SELECT TRIM(name, ' ' || char(9, 10, 11, 12, 13)), description, created_at, updated_at + FROM projects + WHERE name != TRIM(name, ' ' || char(9, 10, 11, 12, 13)) + AND TRIM(name, ' ' || char(9, 10, 11, 12, 13)) != ''; +DELETE FROM projects WHERE name != TRIM(name, ' ' || char(9, 10, 11, 12, 13)); `, } diff --git a/internal/db/items.go b/internal/db/items.go index d9519e7..953f35c 100644 --- a/internal/db/items.go +++ b/internal/db/items.go @@ -18,6 +18,8 @@ func (db *DB) CreateItem(item *model.Item) error { return fmt.Errorf("invalid status: %s", item.Status) } + item.Project = model.NormalizeProject(item.Project) + // Auto-create project if specified if item.Project != "" { if err := db.EnsureProject(item.Project); err != nil { @@ -152,6 +154,8 @@ func (db *DB) SetParent(itemID, parentID string) error { // SetProject changes an item's project. func (db *DB) SetProject(id string, project string) error { + project = model.NormalizeProject(project) + // Auto-create project if specified if project != "" { if err := db.EnsureProject(project); err != nil { diff --git a/internal/db/projects.go b/internal/db/projects.go index 0ee0f62..ed4ae81 100644 --- a/internal/db/projects.go +++ b/internal/db/projects.go @@ -3,11 +3,14 @@ package db import ( "fmt" "time" + + "github.com/baiirun/prog/internal/model" ) // EnsureProject creates a project if it doesn't exist. // This is idempotent - calling it multiple times with the same name is safe. func (db *DB) EnsureProject(name string) error { + name = model.NormalizeProject(name) _, err := db.Exec(` INSERT INTO projects (name, created_at, updated_at) VALUES (?, ?, ?) diff --git a/internal/db/projects_test.go b/internal/db/projects_test.go index d616feb..0d99896 100644 --- a/internal/db/projects_test.go +++ b/internal/db/projects_test.go @@ -2,6 +2,9 @@ package db import ( "testing" + "time" + + "github.com/baiirun/prog/internal/model" ) func TestEnsureProject(t *testing.T) { @@ -42,3 +45,81 @@ func TestListProjectsEmpty(t *testing.T) { t.Errorf("expected empty list, got %v", projects) } } + +// Regression: `prog add -p " spindle"` stored the project verbatim, so +// `prog list -p spindle` returned nothing even though show printed "spindle". +func TestCreateItemNormalizesProjectForFiltering(t *testing.T) { + db := setupTestDB(t) + + item := &model.Item{ + ID: model.GenerateID(model.ItemTypeTask), + Project: " brandnew\t", + Type: model.ItemTypeTask, + Title: "task in a brand-new project", + Status: model.StatusOpen, + Priority: 2, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := db.CreateItem(item); err != nil { + t.Fatalf("failed to create item: %v", err) + } + + items, err := db.ListItemsFiltered(ListFilter{Project: "brandnew"}) + if err != nil { + t.Fatalf("failed to list items: %v", err) + } + if len(items) != 1 || items[0].ID != item.ID { + t.Fatalf("list -p brandnew: got %d items, want [%s]", len(items), item.ID) + } + + projects, err := db.ListProjects() + if err != nil { + t.Fatalf("failed to list projects: %v", err) + } + if len(projects) != 1 || projects[0] != "brandnew" { + t.Errorf("projects = %q, want [brandnew]", projects) + } +} + +// Existing rows written before normalization must become filterable after +// the startup migration, without a manual step. +func TestMigrateTrimsExistingProjectNames(t *testing.T) { + db := setupTestDB(t) + + for _, stmt := range []string{ + `INSERT INTO projects (name) VALUES (' spindle'), ('spindle')`, + `INSERT INTO items (id, project, type, title, description, status) VALUES + ('ep-old', ' spindle', 'epic', 'old epic', '', 'open'), + ('ts-new', 'spindle', 'task', 'new task', '', 'open')`, + `PRAGMA user_version = 3`, + } { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("seed %q: %v", stmt, err) + } + } + + if err := db.Migrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + // Idempotent: a second startup is a no-op. + if err := db.Migrate(); err != nil { + t.Fatalf("second migrate: %v", err) + } + + items, err := db.ListItemsFiltered(ListFilter{Project: "spindle"}) + if err != nil { + t.Fatalf("failed to list items: %v", err) + } + if len(items) != 2 { + t.Fatalf("list -p spindle: got %d items, want 2", len(items)) + } + + projects, err := db.ListProjects() + if err != nil { + t.Fatalf("failed to list projects: %v", err) + } + if len(projects) != 1 || projects[0] != "spindle" { + t.Errorf("projects = %q, want [spindle]", projects) + } +} diff --git a/internal/model/item.go b/internal/model/item.go index 2e9e0de..1d694d1 100644 --- a/internal/model/item.go +++ b/internal/model/item.go @@ -4,9 +4,20 @@ package model import ( "crypto/rand" "encoding/hex" + "strings" "time" ) +// NormalizeProject returns the canonical form of a project name. +// +// Project names are matched with exact string equality everywhere (filters, +// labels, learnings), so surrounding whitespace from a sloppy -p value would +// otherwise create a distinct, visually identical project that "-p name" +// never matches. +func NormalizeProject(name string) string { + return strings.TrimSpace(name) +} + // GenerateID returns a new ID with a type-specific prefix and 6 hex chars. // // Prefixes by item type: