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
9 changes: 7 additions & 2 deletions cmd/prog/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ Quick start:
prog ready -p myproject
prog start <id>
prog done <id>`,
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{
Expand Down Expand Up @@ -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
},
}
Expand Down
23 changes: 22 additions & 1 deletion internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
`,
}

Expand Down
4 changes: 4 additions & 0 deletions internal/db/items.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/db/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (?, ?, ?)
Expand Down
81 changes: 81 additions & 0 deletions internal/db/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package db

import (
"testing"
"time"

"github.com/baiirun/prog/internal/model"
)

func TestEnsureProject(t *testing.T) {
Expand Down Expand Up @@ -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)
}
}
11 changes: 11 additions & 0 deletions internal/model/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading