diff --git a/CLAUDE.md b/CLAUDE.md index 67daa88..b028404 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,9 +30,9 @@ cmd/ init.go — `tasklin init` interactive setup transition.go — `tasklin _transition` called by git hooks internal/ - model/ — pure data types: Ticket, Status, Config, GlobalState - store/ — YAML read/write, NextID, SortedStatuses, branch-state helpers - git/ — git root detection, current branch, IsMainBranch + model/ — pure data types: Ticket, Status, Config + store/ — YAML read/write, NewID, SortedStatuses, MigrateIfNeeded + git/ — git root detection, current branch hooks/ — git hook file generation (post-commit, post-merge, pre-commit) tui/ — all TUI logic (single file: tui.go) resources/ @@ -87,7 +87,8 @@ Each mode has a dedicated `handle*` method and a `view*` method. ### Data layer - All persistence goes through `internal/store` — never read/write YAML files directly from the TUI -- `store.NextID()` always reads both `tickets.yaml` and `deleted.yaml` to avoid ID reuse +- Each ticket has its own file: `store.WriteTicket(t)` writes `tickets/.yaml`; `store.DeleteTicketFile(id)` removes it +- `store.NewID()` generates a random 8-char hex ID; never use sequential integers - `store.SortedStatuses()` must be called whenever `m.cfg.Statuses` is mutated to keep `m.statuses` consistent - After renaming a status, migrate all tickets referencing the old name before persisting - `store.ReadLabels()` / `store.WriteLabels()` manage `.todo/labels.yaml`; call `updateKnownLabels()` (not `WriteLabels` directly) from the TUI so the in-memory slice stays consistent @@ -95,7 +96,7 @@ Each mode has a dedicated `handle*` method and a `view*` method. ### TUI mutations - Status mutations (`addStatus`, `deleteStatus`, etc.) must also reset `m.colScroll` to `make([]int, len(m.statuses))` to avoid stale offsets - `clampScroll()` must be called after any change to `m.rowIdx` or `m.colIdx` on the board -- `m.persist()` saves the current ticket slice to `tickets.yaml`; call it after any ticket mutation +- For ticket mutations, call `m.store.WriteTicket(m.tickets[i])` on the changed ticket; do not rewrite the entire list ### Shell scripts in auto-commit - Always use `bash -c` (not `sh -c`) — the script uses process substitution `< <(...)` which is bash-only diff --git a/README.md b/README.md index 87711c8..fcb9182 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ You will be prompted to: - Keep or customise the default statuses (To Do / In Progress / Done) - Optionally install git hooks that auto-transition tickets on commit/merge -This creates a `.todo/` folder with `config.yaml` and an empty `tickets.yaml`. +This creates a `.todo/` folder with `config.yaml` and empty `tickets/` and `deleted/` directories. ### 2. Open the TUI @@ -86,7 +86,7 @@ If `.todo/` does not exist yet, the init flow runs automatically. | `l` | Edit labels on the selected ticket | | `/` | Filter board by label | | `m` | Open the move dialog to pick a target status | -| `d` | Delete the selected ticket (soft-deleted to `deleted.yaml`) | +| `d` | Delete the selected ticket (soft-deleted to `deleted/`) | | `c` | Open the config screen | | `?` | Show help overlay | | `q` / `Ctrl+C` | Quit | @@ -126,10 +126,10 @@ All data lives in `.todo/` at the project root and is plain YAML — safe to com ``` .todo/ -├── config.yaml # statuses, title limit, default done status, auto-commit flag -├── tickets.yaml # active tickets -├── deleted.yaml # soft-deleted tickets (never permanently removed) -└── labels.yaml # index of all known labels (used for autocomplete) +├── config.yaml # statuses, title limit, default done status, auto-commit flag +├── tickets/ # one YAML file per active ticket (e.g. ab3f92c1.yaml) +├── deleted/ # one YAML file per soft-deleted ticket (never removed) +└── labels.yaml # index of all known labels (used for autocomplete) ``` ### config.yaml fields @@ -162,28 +162,22 @@ When `auto_commit_on_done` is enabled, moving a ticket to the Done status trigge 1. Any **new (untracked) files** are listed — confirm each with `y/N` 2. Any **deleted files** are listed — confirm each with `y/N` 3. `git add -p` runs for interactive hunk selection on modified files -4. A commit is created with the message `[ID] Title` if anything was staged +4. A commit is created with the message `[ab3f92c1] Title` (8-char hex ticket ID) if anything was staged Enable it from the in-app config screen (`c`) or by editing `.todo/config.yaml` directly. -### Global state - -Branch-state tracking (used when working on non-main branches) is stored at: - -``` -~/.config/tasklin/state.yaml -``` - --- ## Git integration When you run `tasklin init` inside a git repository you are offered the option to install hooks: -- **post-commit** — if the commit message starts with `[ID]`, transitions that ticket to the done status -- **post-merge** — if the merged branch name contains `[ID]`, transitions that ticket +- **commit-msg** — if the commit message starts with `[ab3f92c1]` (8-char hex ID), transitions that ticket to the done status +- **post-merge** — if the merged branch name contains `[ab3f92c1]`, transitions that ticket - **pre-commit** *(optional)* — automatically stages `.todo/` in every commit +If you installed hooks before upgrading to per-file storage, run `tasklin` once to trigger automatic hook reinstall, or re-run `tasklin init` to reinstall manually. + --- ## Building & development diff --git a/cmd/root.go b/cmd/root.go index 42e4572..13a769c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,8 @@ import ( "fmt" "os" + internalgit "github.com/frankcruz/tasklin/internal/git" + "github.com/frankcruz/tasklin/internal/hooks" "github.com/frankcruz/tasklin/internal/store" "github.com/frankcruz/tasklin/internal/tui" "github.com/spf13/cobra" @@ -30,6 +32,18 @@ func runRoot(cmd *cobra.Command, args []string) error { } } + migrated, err := s.MigrateIfNeeded() + if err != nil { + return fmt.Errorf("migration: %w", err) + } + if migrated { + gitRoot := internalgit.RepoRoot(cwd) + if gitRoot != "" { + cfg, _ := s.ReadConfig() + hooks.ReinstallIfPresent(internalgit.GitDir(gitRoot), cfg.DefaultDoneStatus) + } + } + return tui.Run(s, cwd) } diff --git a/cmd/transition.go b/cmd/transition.go index 8035de5..b68f5ab 100644 --- a/cmd/transition.go +++ b/cmd/transition.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "strconv" "time" "github.com/frankcruz/tasklin/internal/model" @@ -21,10 +20,7 @@ var transitionCmd = &cobra.Command{ } func runTransition(cmd *cobra.Command, args []string) error { - ticketID, err := strconv.Atoi(args[0]) - if err != nil { - return fmt.Errorf("invalid ticket id: %s", args[0]) - } + ticketID := args[0] targetStatus := args[1] cwd, err := os.Getwd() @@ -41,26 +37,18 @@ func runTransition(cmd *cobra.Command, args []string) error { return err } - found := false for i, t := range tickets { if t.ID == ticketID { - if t.Status == targetStatus { return nil // No change needed } - tr := model.Transition{From: t.Status, To: targetStatus, At: time.Now().UTC()} tickets[i].Status = targetStatus tickets[i].Transitions = append(tickets[i].Transitions, tr) - found = true - break + return s.WriteTicket(tickets[i]) } } - if !found { - return fmt.Errorf("ticket %d not found", ticketID) - } - - return s.WriteTickets(tickets) + return fmt.Errorf("ticket %s not found", ticketID) } func init() { diff --git a/docs/architecture.md b/docs/architecture.md index b0ab1be..62db97d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,22 +40,21 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B │ │store │ │model │ │git │ │ │ │ │ │ │ │ │ │ │ │ReadTickets │ │Ticket │ │RepoRoot │ │ -│ │WriteTickets │ │Status │ │CurrentBranch │ │ -│ │ReadConfig │ │Config │ │IsMainBranch │ │ -│ │WriteConfig │ │GlobalState │ └───────────────────┘ │ -│ │NextID │ └────────────┘ │ -│ │BranchState │ ┌────────────────────┐ │ -│ └──────┬──────┘ │internal/hooks │ │ -│ │ │ │ │ -│ │ │WritePostCommit │ │ -│ ┌──────▼────────────────┐ │WritePostMerge │ │ -│ │ .todo/ (local YAML) │ │WritePreCommit │ │ +│ │WriteTicket │ │Status │ │CurrentBranch │ │ +│ │ReadConfig │ │Config │ └───────────────────┘ │ +│ │WriteConfig │ └────────────┘ │ +│ │NewID │ ┌────────────────────┐ │ +│ │MigrateIfNeeded │internal/hooks │ │ +│ └──────┬──────┘ │ │ │ +│ │ │InstallCommitMsg │ │ +│ │ │InstallPostMerge │ │ +│ ┌──────▼────────────────┐ │InstallPreCommit │ │ +│ │ .todo/ (local YAML) │ │ReinstallIfPresent │ │ │ │ │ └────────────────────┘ │ │ │ config.yaml │ │ -│ │ tickets.yaml │ ┌──────────────────────────┐ │ -│ │ deleted.yaml │ │ ~/.config/tasklin/ │ │ -│ └───────────────────────┘ │ state.yaml │ │ -│ └──────────────────────────┘ │ +│ │ tickets/.yaml │ │ +│ │ deleted/.yaml │ │ +│ └───────────────────────┘ │ └───────────────────────────────────────────────────────────┘ ``` @@ -67,7 +66,7 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B | File | Responsibility | |---|---| -| `root.go` | Cobra root command; detects if `.todo/` exists; calls `tui.Run()` | +| `root.go` | Cobra root command; detects if `.todo/` exists; runs migration; calls `tui.Run()` | | `init.go` | `tasklin init` interactive setup wizard; calls `store.Init()` and `hooks` package | | `transition.go` | `tasklin _transition ` — internal command used by git hooks only | @@ -75,24 +74,25 @@ The binary is structured as a thin `cobra` CLI wrapper around a self-contained B Pure data types with no logic beyond defaults. Nothing in this package reads or writes files. -- `Ticket` — id, title, status, created_at, transitions +- `Ticket` — id (string), title, status, created_at, transitions - `Status` — id, name, color, order - `Config` — title_limit, default_done_status, auto_commit_on_done, statuses - `Transition` — from, to, at -- `GlobalState` / `BranchTicket` — branch-level status overrides - `DefaultStatuses()` / `DefaultConfig()` — sensible built-in values ### `internal/store/` All YAML persistence. The TUI and CLI never touch the filesystem directly. -- `Store.ReadTickets()` / `WriteTickets()` — active tickets -- `Store.ReadDeleted()` / `WriteDeleted()` — soft-deleted tickets +- `Store.ReadTickets()` — reads all `*.yaml` from `tickets/` directory +- `Store.WriteTicket(t)` — writes a single ticket to `tickets/.yaml` +- `Store.DeleteTicketFile(id)` — removes `tickets/.yaml` +- `Store.WriteDeletedTicket(t)` — writes a ticket to `deleted/.yaml` +- `Store.ReadDeleted()` — reads all `*.yaml` from `deleted/` directory - `Store.ReadConfig()` / `WriteConfig()` — project config -- `Store.NextID()` — reads both tickets and deleted to guarantee no ID reuse +- `NewID()` — generates a random 8-char hex ID via `crypto/rand` - `SortedStatuses()` — returns statuses ordered by their `Order` field -- `ReadGlobalState()` / `WriteGlobalState()` — `~/.config/tasklin/state.yaml` -- `GetBranchOverrides()` / `ApplyBranchOverrides()` / `SetBranchOverride()` — branch-state helpers +- `Store.MigrateIfNeeded()` — converts legacy single-file format to per-file on startup ### `internal/git/` @@ -100,15 +100,16 @@ Thin wrappers around `git` shell calls. No state. - `RepoRoot(dir)` — walks up the directory tree to find `.git/` - `CurrentBranch(dir)` — runs `git rev-parse --abbrev-ref HEAD` -- `IsMainBranch(branch)` — returns true for `main` / `master` +- `GitDir(root)` — returns path to `.git/` directory ### `internal/hooks/` -Generates the text content of git hook scripts. Does not write them to disk itself — `cmd/init.go` does that. +Generates the text content of git hook scripts. `cmd/init.go` installs them; `cmd/root.go` calls `ReinstallIfPresent` after migration. -- `PostCommitHook(binary, status)` — script that transitions ticket on commit -- `PostMergeHook(binary, status)` — script that transitions ticket on merge -- `PreCommitHook()` — script that stages `.todo/` +- `InstallCommitMsg(gitDir, status)` — hook that transitions ticket on commit +- `InstallPostMerge(gitDir, status)` — hook that transitions ticket on merge +- `InstallPreCommit(gitDir)` — hook that stages `.todo/` +- `ReinstallIfPresent(gitDir, status)` — updates existing tasklin hooks to current format ### `internal/tui/` @@ -118,7 +119,7 @@ The entire TUI lives in a single file: `tui.go`. It follows the standard Bubble - `Init()` — returns nil (no startup commands) - `Update(msg)` — dispatches to per-mode `handle*` methods - `View()` — dispatches to per-mode `view*` methods -- Data mutations go through `m.persist()` to `store.WriteTickets()` +- Data mutations call targeted store methods (`WriteTicket`, `DeleteTicketFile`, `WriteDeletedTicket`) --- @@ -131,11 +132,11 @@ main() └── cmd.Execute() └── root.go: store.New() → store.Initialised()? ├── No → cmd/init.go: interactive init wizard - └── Yes → tui.New(store, projectDir) - ├── store.ReadConfig() + └── Yes → store.MigrateIfNeeded() + └── (converts tickets.yaml → tickets/ if needed) + tui.New(store, projectDir) ├── store.ReadTickets() - ├── git.CurrentBranch() - └── store.ApplyBranchOverrides() (if non-main branch) + └── git.CurrentBranch() tea.NewProgram(model).Run() ``` @@ -148,8 +149,7 @@ keypress (Shift+→) ├── finds ticket in m.tickets[] ├── appends Transition{from, to, at: now} ├── updates ticket.Status - └── m.persist() - └── store.WriteTickets(m.tickets) + └── store.WriteTicket(ticket) ← single file write ``` ### Auto-commit flow @@ -167,20 +167,9 @@ ticket moved to DefaultDoneStatus └── git commit -m "[ID] Title" ``` -### Branch state tracking +### Parallel agent safety -``` -TUI startup (non-main branch) - └── store.ReadGlobalState() - └── store.ApplyBranchOverrides(tickets, overrides) - └── overrides shadow ticket.Status in memory only - -ticket moved on non-main branch - └── store.SetBranchOverride(gs, projectDir, branch, ticketID, newStatus) - └── store.WriteGlobalState(gs) - → ~/.config/tasklin/state.yaml updated - (tickets.yaml is NOT modified) -``` +Each ticket is stored as an independent file (`tickets/.yaml`). Two agents working on different tickets modify different files — git merges cleanly with no conflicts. IDs are random hex strings from `crypto/rand`, so agents on different machines cannot produce colliding IDs. --- @@ -190,9 +179,10 @@ ticket moved on non-main branch |---|---| | Single binary, no daemon | Easy to install, version, and distribute | | YAML over SQLite/JSON | Human-readable, diffable, committable alongside code | +| One file per ticket | Parallel agents on different tickets produce no merge conflicts | +| Random hex IDs | Collision-free across machines without coordination | | All TUI in one file | Reduces navigation overhead for a tightly coupled UI | | Value receivers on `Model` (Bubble Tea convention) | Bubble Tea requires `Update` to return a new model; pointer receivers are used only for multi-step mutations | | `store` is the only persistence layer | Keeps the TUI testable without hitting disk | | `bash` (not `sh`) for auto-commit script | Script uses `< <(...)` process substitution, which is bash-only | -| Soft delete to `deleted.yaml` | Prevents ID reuse; allows recovery | -| Global state in `~/.config/tasklin/` | Branch overrides are user-scoped, not project-scoped | +| Soft delete to `deleted/` | Preserves history; allows recovery | diff --git a/docs/data-model.md b/docs/data-model.md index c6b6c25..faca533 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -7,13 +7,13 @@ All tasklin data is plain YAML. There is no database — the files are designed ``` / └── .todo/ - ├── config.yaml ← project configuration and statuses - ├── tickets.yaml ← active tickets - ├── deleted.yaml ← soft-deleted tickets (never purged) - └── labels.yaml ← known labels index (autocomplete source) - -~/.config/tasklin/ -└── state.yaml ← branch-level status overrides (global, user-scoped) + ├── config.yaml ← project configuration and statuses + ├── tickets/ ← one YAML file per active ticket + │ ├── ab3f92c1.yaml + │ └── d7e10f44.yaml + ├── deleted/ ← one YAML file per soft-deleted ticket + │ └── 00000003.yaml + └── labels.yaml ← known labels index (autocomplete source) ``` --- @@ -42,17 +42,10 @@ All tasklin data is plain YAML. There is no database — the files are designed └─────────────────────────┘ -┌─────────────────────────────────────────────────────────────┐ -│ TicketFile │ -├─────────────────────────────────────────────────────────────┤ -│ Tickets []Ticket │ -└───────────────────────────┬─────────────────────────────────┘ - │ contains 0..* - ▼ ┌──────────────────────────────┐ │ Ticket │ ├──────────────────────────────┤ - │ ID int │ + │ ID string │ ← 8-char lowercase hex │ Title string │ │ Status string ─────────┼──► Status.Name (soft ref) │ Labels []string │ @@ -68,21 +61,6 @@ All tasklin data is plain YAML. There is no database — the files are designed │ To string │ │ At time.Time │ └──────────────────────────┘ - - -┌──────────────────────────────────────────────────────────────┐ -│ GlobalState │ -├──────────────────────────────────────────────────────────────┤ -│ Projects map[projectPath]map[branch][]BranchTicket │ -└────────────────────────────┬─────────────────────────────────┘ - │ - ▼ - ┌──────────────────────────┐ - │ BranchTicket │ - ├──────────────────────────┤ - │ TicketID int │ - │ Status string │ - └──────────────────────────┘ ``` **Note:** `Ticket.Status` holds a status **name string**, not an ID. This is intentional — YAML files remain readable without a lookup table. When a status is renamed, a migration loop updates all ticket status strings to match. @@ -122,51 +100,44 @@ statuses: --- -## tickets.yaml +## tickets/\.yaml + +Each active ticket is stored as its own file. The filename is the ticket ID (e.g. `ab3f92c1.yaml`). ```yaml -tickets: - - id: 1 - title: "Set up CI pipeline" - status: "In Progress" - labels: - - bug - - backend - created_at: 2026-01-14T09:00:00Z - transitions: - - from: "To Do" - to: "In Progress" - at: 2026-01-15T11:30:00Z - - id: 2 - title: "Initialise repository" - status: "Done" - created_at: 2026-01-10T08:00:00Z - transitions: - - from: "To Do" - to: "Done" - at: 2026-01-10T16:00:00Z +id: ab3f92c1 +title: "Set up CI pipeline" +status: "In Progress" +labels: + - bug + - backend +created_at: 2026-01-14T09:00:00Z +transitions: + - from: "To Do" + to: "In Progress" + at: 2026-01-15T11:30:00Z ``` **Rules:** -- `id` is globally unique and monotonically increasing -- `id` is never reused, even after deletion (`NextID` reads `deleted.yaml` too) +- `id` is a random 8-character lowercase hex string generated by `store.NewID()` +- IDs are globally collision-resistant (4 bytes of `crypto/rand`) - `labels` is omitted when empty (YAML `omitempty`); zero labels is valid - Each label must match `[A-Za-z][A-Za-z0-9_]*` — validated at input time in the TUI - `transitions` is omitted when empty (YAML `omitempty`) - Transition history is append-only — never mutated after the fact +- Tickets within a column are sorted by `created_at` for display ordering --- -## deleted.yaml +## deleted/\.yaml -Same schema as `tickets.yaml`. Tickets are moved here when deleted from the TUI. The file is created on first deletion. +Same schema as tickets. When a ticket is deleted from the TUI, its file is moved from `tickets/` to `deleted/`. Deleted tickets are never purged — the directory preserves history. ```yaml -tickets: - - id: 3 - title: "Old spike task" - status: "To Do" - created_at: 2026-01-12T10:00:00Z +id: 00000003 +title: "Old spike task" +status: "To Do" +created_at: 2026-01-12T10:00:00Z ``` --- @@ -191,40 +162,17 @@ labels: --- -## state.yaml (`~/.config/tasklin/state.yaml`) - -Tracks branch-level status overrides. Written when a ticket is moved on a non-main branch. Does **not** modify `tickets.yaml` — overrides are applied in memory at TUI startup. - -```yaml -projects: - /home/user/my-project: - main: [] - feature/auth-refactor: - - ticket_id: 7 - status: "In Progress" - - ticket_id: 12 - status: "Review" -``` - -**Rules:** -- Keyed by absolute project path, then by branch name -- When the TUI starts on a non-main branch, `ApplyBranchOverrides` shadows `Ticket.Status` in memory -- `tickets.yaml` is only updated when mutations happen on the main branch - ---- - ## ID generation -`store.NextID()` guarantees uniqueness across the full lifetime of a project: +`store.NewID()` generates a collision-resistant 8-character lowercase hex string using `crypto/rand`: ``` -NextID() - ├── ReadTickets() → active ticket IDs - ├── ReadDeleted() → deleted ticket IDs - └── return max(all IDs) + 1 +NewID() + └── crypto/rand.Read(4 bytes) + └── fmt.Sprintf("%08x", bytes) → e.g. "ab3f92c1" ``` -This means if tickets 1–10 exist and tickets 3 and 7 were deleted, the next ID is 11, not 3 or 7. +IDs are random and independent — two agents running in parallel on different machines will never produce the same ID. --- @@ -232,5 +180,11 @@ This means if tickets 1–10 exist and tickets 3 and 7 were deleted, the next ID `Ticket.Status` stores the status name as a plain string, not an integer ID. This has two implications: -1. **Human-readable YAML** — you can read and edit `tickets.yaml` without a lookup table +1. **Human-readable YAML** — you can read and edit ticket files without a lookup table 2. **Rename migration required** — when a status is renamed in the TUI, `updateStatusName()` iterates all tickets and updates the string in-place before persisting + +--- + +## Migration from legacy format + +If `tickets.yaml` or `deleted.yaml` are found on startup, `store.MigrateIfNeeded()` automatically converts them to the per-file format. Legacy integer IDs (`id: 3`) become string IDs (`id: "3"`). Old files are renamed to `.bak`. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 930e49f..43d80f9 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -41,8 +41,8 @@ The sample project is regenerated automatically on first run. Use `make sample C The TUI (`internal/tui/tui.go`) never reads or writes files directly. All persistence goes through `internal/store`: ```go -// correct -m.persist() // calls store.WriteTickets(m.tickets) +// correct — write only the ticket that changed +_ = m.store.WriteTicket(m.tickets[i]) // wrong — don't do this yaml.Marshal(m.tickets) @@ -291,7 +291,7 @@ type Ticket struct { In `viewBoard()`, the title line is built as: ```go -title := truncate(fmt.Sprintf("[%d] %s", t.ID, t.Title), contentWidth-3) +label := fmt.Sprintf("[%s] %s", t.ID, t.Title) ``` Adjust this to include your field if appropriate. @@ -309,36 +309,29 @@ Add the field to the YAML schema example in `docs/data-model.md`. ### Reading ```go -tickets, err := s.ReadTickets() +tickets, err := s.ReadTickets() // reads all *.yaml from tickets/ cfg, err := s.ReadConfig() -deleted, err := s.ReadDeleted() +deleted, err := s.ReadDeleted() // reads all *.yaml from deleted/ ``` -### Writing +### Writing a single ticket -```go -_ = s.WriteTickets(tickets) -_ = s.WriteConfig(cfg) -``` - -### Getting the next ID +Always write only the ticket that changed — do not rewrite the entire list: ```go -id, err := s.NextID() // reads both tickets.yaml and deleted.yaml +_ = s.WriteTicket(ticket) // creates/updates tickets/.yaml +_ = s.WriteDeletedTicket(ticket) // creates deleted/.yaml +_ = s.DeleteTicketFile(ticket.ID) // removes tickets/.yaml ``` -### Branch state +### Generating a new ID ```go -gs, err := store.ReadGlobalState() -overrides := store.GetBranchOverrides(gs, projectDir, branch) -tickets = store.ApplyBranchOverrides(tickets, overrides) - -// after a move on a non-main branch: -store.SetBranchOverride(gs, projectDir, branch, ticketID, newStatus) -_ = store.WriteGlobalState(gs) +id, err := store.NewID() // returns a random 8-char hex string, e.g. "ab3f92c1" ``` +IDs are random and collision-resistant across machines — no coordination required. + --- ## Adding a git hook @@ -389,19 +382,16 @@ make test-ci # CI mode, writes coverage.out ### Writing a test -Prefer table-driven tests: +Prefer table-driven tests. Use string IDs when constructing fixtures: ```go -func TestNextID(t *testing.T) { +func TestSomeBehaviour(t *testing.T) { cases := []struct { name string - active []model.Ticket - deleted []model.Ticket - want int + tickets []model.Ticket + want string }{ - {"empty store", nil, nil, 1}, - {"active only", tickets(1, 2, 3), nil, 4}, - {"with deleted", tickets(1, 2), tickets(3, 5), 6}, + {"single ticket", []model.Ticket{{ID: "abc00001", Status: "To Do"}}, "abc00001"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -432,9 +422,10 @@ Use the exported accessor methods (`ColIdx()`, `RowIdx()`, `Mode()`) rather than tasklin stores everything as human-readable YAML. When something looks wrong in the TUI, check the files directly: ```sh -cat .todo/tickets.yaml +ls .todo/tickets/ # list active ticket files +cat .todo/tickets/ab3f92c1.yaml cat .todo/config.yaml -cat ~/.config/tasklin/state.yaml +ls .todo/deleted/ # list deleted ticket files ``` ### Run against a clean sample diff --git a/docs/ui-reference.md b/docs/ui-reference.md index 7d729ff..9d62b15 100644 --- a/docs/ui-reference.md +++ b/docs/ui-reference.md @@ -15,15 +15,15 @@ The main screen. Three or more columns, one per status, rendered side by side. ║ ╠═╣╚═╗╠╩╗║ ║║║║ ╩ ╩ ╩╚═╝╩ ╩╩═╝╩╝╚╝ ⎇ main ──────────────────────────────────────────────────────────── - TO DO (4) IN PROGRESS (2) DONE (3) + TO DO (4) IN PROGRESS (2) DONE (3) ──────────────────────────────────────────────────────────── -▌ [1] Set up CI [4] Write unit tests [2] Init repo -▌ [bug] [backend] [backend] [security] [chore] +▌ [ab3f92c1] Set up CI [d7e10f44] Write tests [00000002] Init repo +▌ [bug] [backend] [backend] [security] [chore] ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - [5] Add search [7] Auth middleware [3] Add models + [f9c21a30] Add search [3b8d5e12] Auth [00000003] Add models [feature] ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ - [6] Dark mode + [c04e9a71] Dark mode [ux] ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ n new d del m move e edit l labels / filter c config ? help q quit @@ -34,7 +34,7 @@ The main screen. Three or more columns, one per status, rendered side by side. - Column headers show the status name (uppercase) and ticket count - The focused ticket is marked with an amber `▌` indicator and bold white text; the indicator extends across all label chip rows of the same ticket - All other tickets render in dim gray -- Tickets are sorted by ID (ascending) within each column +- Tickets are sorted by creation time (`created_at`, ascending) within each column - Labels are shown as `[chip]` rows below the title, up to 2 rows per ticket; chips are cyan, bright cyan when the ticket is selected - A dim `╌` separator line appears between each ticket for visual separation - A slim scrollbar appears on the right edge of any column that overflows: @@ -63,7 +63,7 @@ Shows the full history of the selected ticket. ║ ╠═╣╚═╗╠╩╗║ ║║║║ ╩ ╩ ╩╚═╝╩ ╩╩═╝╩╝╚╝ ⎇ main ──────────────────────────────────────────────────────────── - Ticket #7 + Ticket #3b8d5e12 Title Auth middleware Status In Progress diff --git a/internal/git/git.go b/internal/git/git.go index 4272f4f..4937081 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -31,7 +31,3 @@ func GitDir(root string) string { return filepath.Join(root, ".git") } -// IsMainBranch returns true if branch is main or master. -func IsMainBranch(branch string) bool { - return branch == "main" || branch == "master" -} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 456cd50..33ac95d 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // findTasklin is a shell snippet that locates the tasklin binary. @@ -31,8 +32,8 @@ fi func InstallCommitMsg(gitDir, doneStatus string) error { script := fmt.Sprintf(`#!/bin/sh MSG=$(cat "$1") -if echo "$MSG" | grep -qE '^\[([0-9]+)\]'; then - TICKET_ID=$(echo "$MSG" | grep -oE '^\[([0-9]+)\]' | tr -d '[]') +if echo "$MSG" | grep -qE '^\[([0-9a-f]{8})\]'; then + TICKET_ID=$(echo "$MSG" | grep -oE '^\[([0-9a-f]{8})\]' | tr -d '[]') %s "$TASKLIN" _transition "$TICKET_ID" "%s" && git add .todo/ fi @@ -46,8 +47,8 @@ fi func InstallPostMerge(gitDir, doneStatus string) error { script := fmt.Sprintf(`#!/bin/sh BRANCH=$(git reflog | awk 'NR==1{print $6}' | sed 's/.*\///') -if echo "$BRANCH" | grep -qE '\[([0-9]+)\]'; then - TICKET_ID=$(echo "$BRANCH" | grep -oE '\[([0-9]+)\]' | tr -d '[]') +if echo "$BRANCH" | grep -qE '\[([0-9a-f]{8})\]'; then + TICKET_ID=$(echo "$BRANCH" | grep -oE '\[([0-9a-f]{8})\]' | tr -d '[]') %s "$TASKLIN" _transition "$TICKET_ID" "%s" && git add .todo/ && git commit --amend --no-edit --no-verify fi @@ -63,6 +64,27 @@ git add .todo/ return writeHook(gitDir, "pre-commit", script) } +// ReinstallIfPresent reinstalls commit-msg and post-merge hooks if they already +// exist and reference tasklin, updating them to the current hook format. +func ReinstallIfPresent(gitDir, doneStatus string) { + for _, name := range []string{"commit-msg", "post-merge"} { + p := filepath.Join(gitDir, "hooks", name) + data, err := os.ReadFile(p) + if err != nil { + continue + } + if !strings.Contains(string(data), "tasklin") { + continue + } + switch name { + case "commit-msg": + _ = InstallCommitMsg(gitDir, doneStatus) + case "post-merge": + _ = InstallPostMerge(gitDir, doneStatus) + } + } +} + func writeHook(gitDir, name, content string) error { p := filepath.Join(gitDir, "hooks", name) if err := os.WriteFile(p, []byte(content), 0755); err != nil { diff --git a/internal/model/model.go b/internal/model/model.go index f5d479a..b70ede8 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -19,7 +19,7 @@ type Transition struct { // Ticket is a single backlog item. type Ticket struct { - ID int `yaml:"id"` + ID string `yaml:"id"` Title string `yaml:"title"` Status string `yaml:"status"` Labels []string `yaml:"labels,omitempty"` @@ -36,23 +36,6 @@ type Config struct { Statuses []Status `yaml:"statuses"` } -// TicketFile is the top-level structure for tickets.yaml / deleted.yaml. -type TicketFile struct { - Tickets []Ticket `yaml:"tickets"` -} - -// BranchTicket records a ticket status override for a branch. -type BranchTicket struct { - TicketID int `yaml:"ticket_id"` - Status string `yaml:"status"` -} - -// GlobalState is the top-level structure for ~/.config/tasklin/state.yaml. -// Structure: projects[projectPath][branch] = []BranchTicket -type GlobalState struct { - Projects map[string]map[string][]BranchTicket `yaml:"projects"` -} - // DefaultStatuses returns the built-in status set. func DefaultStatuses() []Status { return []Status{ diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..9a4b180 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,103 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/frankcruz/tasklin/internal/model" +) + +// legacyTicket parses the old integer-ID ticket format. +type legacyTicket struct { + ID int `yaml:"id"` + Title string `yaml:"title"` + Status string `yaml:"status"` + Labels []string `yaml:"labels,omitempty"` + CreatedAt time.Time `yaml:"created_at"` + Transitions []model.Transition `yaml:"transitions,omitempty"` +} + +type legacyTicketFile struct { + Tickets []legacyTicket `yaml:"tickets"` +} + +// MigrateIfNeeded migrates from the legacy single-file format to per-file storage. +// Returns true if any migration was performed. +func (s *Store) MigrateIfNeeded() (bool, error) { + migratedTickets, err1 := s.migrateTickets() + migratedDeleted, err2 := s.migrateDeleted() + if migratedTickets || migratedDeleted { + deleteGlobalState() + } + return migratedTickets || migratedDeleted, errors.Join(err1, err2) +} + +func (s *Store) migrateTickets() (bool, error) { + oldPath := filepath.Join(s.TodoPath(), "tickets.yaml") + if _, err := os.Stat(oldPath); os.IsNotExist(err) { + return false, nil + } + var ltf legacyTicketFile + if err := readYAML(oldPath, <f); err != nil { + return false, fmt.Errorf("migrate tickets: %w", err) + } + if err := os.MkdirAll(s.ticketsPath(), 0755); err != nil { + return false, err + } + for _, lt := range ltf.Tickets { + t := model.Ticket{ + ID: strconv.Itoa(lt.ID), + Title: lt.Title, + Status: lt.Status, + Labels: lt.Labels, + CreatedAt: lt.CreatedAt, + Transitions: lt.Transitions, + } + if err := s.WriteTicket(t); err != nil { + return false, err + } + } + _ = os.Rename(oldPath, oldPath+".bak") + return true, nil +} + +func (s *Store) migrateDeleted() (bool, error) { + oldPath := filepath.Join(s.TodoPath(), "deleted.yaml") + if _, err := os.Stat(oldPath); os.IsNotExist(err) { + return false, nil + } + var ltf legacyTicketFile + if err := readYAML(oldPath, <f); err != nil { + return false, fmt.Errorf("migrate deleted: %w", err) + } + if err := os.MkdirAll(s.deletedPath(), 0755); err != nil { + return false, err + } + for _, lt := range ltf.Tickets { + t := model.Ticket{ + ID: strconv.Itoa(lt.ID), + Title: lt.Title, + Status: lt.Status, + Labels: lt.Labels, + CreatedAt: lt.CreatedAt, + Transitions: lt.Transitions, + } + if err := s.WriteDeletedTicket(t); err != nil { + return false, err + } + } + _ = os.Rename(oldPath, oldPath+".bak") + return true, nil +} + +func deleteGlobalState() { + dir, err := os.UserConfigDir() + if err != nil { + return + } + _ = os.Remove(filepath.Join(dir, "tasklin", "state.yaml")) +} diff --git a/internal/store/state.go b/internal/store/state.go deleted file mode 100644 index 9a90176..0000000 --- a/internal/store/state.go +++ /dev/null @@ -1,100 +0,0 @@ -package store - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/frankcruz/tasklin/internal/model" - "gopkg.in/yaml.v3" -) - -func globalStatePath() (string, error) { - dir, err := os.UserConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, "tasklin", "state.yaml"), nil -} - -// ReadGlobalState reads ~/.config/tasklin/state.yaml. -func ReadGlobalState() (model.GlobalState, error) { - var gs model.GlobalState - p, err := globalStatePath() - if err != nil { - return gs, err - } - if _, err := os.Stat(p); os.IsNotExist(err) { - return model.GlobalState{Projects: map[string]map[string][]model.BranchTicket{}}, nil - } - data, err := os.ReadFile(p) - if err != nil { - return gs, err - } - if err := yaml.Unmarshal(data, &gs); err != nil { - return gs, fmt.Errorf("parse state.yaml: %w", err) - } - if gs.Projects == nil { - gs.Projects = map[string]map[string][]model.BranchTicket{} - } - return gs, nil -} - -// WriteGlobalState writes ~/.config/tasklin/state.yaml. -func WriteGlobalState(gs model.GlobalState) error { - p, err := globalStatePath() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { - return err - } - data, err := yaml.Marshal(gs) - if err != nil { - return err - } - return os.WriteFile(p, data, 0644) -} - -// GetBranchOverrides returns branch-level ticket status overrides for a project+branch. -func GetBranchOverrides(gs model.GlobalState, projectPath, branch string) []model.BranchTicket { - if proj, ok := gs.Projects[projectPath]; ok { - return proj[branch] - } - return nil -} - -// SetBranchOverride sets a ticket status override for a project+branch. -func SetBranchOverride(gs *model.GlobalState, projectPath, branch string, ticketID int, status string) { - if gs.Projects == nil { - gs.Projects = map[string]map[string][]model.BranchTicket{} - } - if gs.Projects[projectPath] == nil { - gs.Projects[projectPath] = map[string][]model.BranchTicket{} - } - overrides := gs.Projects[projectPath][branch] - for i, bt := range overrides { - if bt.TicketID == ticketID { - overrides[i].Status = status - gs.Projects[projectPath][branch] = overrides - return - } - } - gs.Projects[projectPath][branch] = append(overrides, model.BranchTicket{TicketID: ticketID, Status: status}) -} - -// ApplyBranchOverrides returns tickets with branch overrides applied (runtime shadow). -func ApplyBranchOverrides(tickets []model.Ticket, overrides []model.BranchTicket) []model.Ticket { - overrideMap := map[int]string{} - for _, bt := range overrides { - overrideMap[bt.TicketID] = bt.Status - } - result := make([]model.Ticket, len(tickets)) - copy(result, tickets) - for i, t := range result { - if s, ok := overrideMap[t.ID]; ok { - result[i].Status = s - } - } - return result -} diff --git a/internal/store/state_test.go b/internal/store/state_test.go deleted file mode 100644 index f2e2e44..0000000 --- a/internal/store/state_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package store_test - -import ( - "testing" - - "github.com/frankcruz/tasklin/internal/model" - "github.com/frankcruz/tasklin/internal/store" -) - -func TestSetAndGetBranchOverride(t *testing.T) { - gs := model.GlobalState{} - store.SetBranchOverride(&gs, "/proj", "feature/x", 1, "In Progress") - store.SetBranchOverride(&gs, "/proj", "feature/x", 2, "Done") - - overrides := store.GetBranchOverrides(gs, "/proj", "feature/x") - if len(overrides) != 2 { - t.Fatalf("expected 2 overrides, got %d", len(overrides)) - } - - // Update existing override. - store.SetBranchOverride(&gs, "/proj", "feature/x", 1, "Done") - overrides = store.GetBranchOverrides(gs, "/proj", "feature/x") - for _, bt := range overrides { - if bt.TicketID == 1 && bt.Status != "Done" { - t.Errorf("ticket 1 override: expected 'Done', got %q", bt.Status) - } - } -} - -func TestGetBranchOverrides_NoProject(t *testing.T) { - gs := model.GlobalState{} - overrides := store.GetBranchOverrides(gs, "/nonexistent", "main") - if overrides != nil { - t.Errorf("expected nil overrides, got %v", overrides) - } -} - -func TestApplyBranchOverrides(t *testing.T) { - tickets := []model.Ticket{ - {ID: 1, Title: "a", Status: "To Do"}, - {ID: 2, Title: "b", Status: "In Progress"}, - {ID: 3, Title: "c", Status: "To Do"}, - } - overrides := []model.BranchTicket{ - {TicketID: 1, Status: "In Progress"}, - {TicketID: 3, Status: "Done"}, - } - result := store.ApplyBranchOverrides(tickets, overrides) - - expected := map[int]string{1: "In Progress", 2: "In Progress", 3: "Done"} - for _, t2 := range result { - if t2.Status != expected[t2.ID] { - t.Errorf("ticket %d: expected status %q, got %q", t2.ID, expected[t2.ID], t2.Status) - } - } -} - -func TestApplyBranchOverrides_DoesNotMutateOriginal(t *testing.T) { - tickets := []model.Ticket{ - {ID: 1, Title: "a", Status: "To Do"}, - } - overrides := []model.BranchTicket{{TicketID: 1, Status: "Done"}} - store.ApplyBranchOverrides(tickets, overrides) - - if tickets[0].Status != "To Do" { - t.Error("original tickets slice was mutated") - } -} diff --git a/internal/store/store.go b/internal/store/store.go index 3c3a3df..495b1ca 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,21 +1,23 @@ package store import ( + "crypto/rand" "fmt" "os" "path/filepath" "sort" + "strings" "github.com/frankcruz/tasklin/internal/model" "gopkg.in/yaml.v3" ) const ( - TodoDir = ".todo" - ConfigFile = "config.yaml" - TicketsFile = "tickets.yaml" - DeletedFile = "deleted.yaml" - LabelsFile = "labels.yaml" + TodoDir = ".todo" + ConfigFile = "config.yaml" + TicketsDir = "tickets" + DeletedDir = "deleted" + LabelsFile = "labels.yaml" ) type labelsIndex struct { @@ -37,6 +39,9 @@ func (s *Store) TodoPath() string { return filepath.Join(s.root, TodoDir) } +func (s *Store) ticketsPath() string { return filepath.Join(s.TodoPath(), TicketsDir) } +func (s *Store) deletedPath() string { return filepath.Join(s.TodoPath(), DeletedDir) } + // Initialised returns true if .todo/ exists. func (s *Store) Initialised() bool { _, err := os.Stat(s.TodoPath()) @@ -51,12 +56,11 @@ func (s *Store) Init(cfg model.Config) error { if err := s.WriteConfig(cfg); err != nil { return err } - // Write empty tickets.yaml if it doesn't exist. - tp := filepath.Join(s.TodoPath(), TicketsFile) - if _, err := os.Stat(tp); os.IsNotExist(err) { - if err := writeYAML(tp, model.TicketFile{}); err != nil { - return err - } + if err := os.MkdirAll(s.ticketsPath(), 0755); err != nil { + return fmt.Errorf("create tickets/: %w", err) + } + if err := os.MkdirAll(s.deletedPath(), 0755); err != nil { + return fmt.Errorf("create deleted/: %w", err) } return nil } @@ -75,36 +79,71 @@ func (s *Store) WriteConfig(cfg model.Config) error { return writeYAML(filepath.Join(s.TodoPath(), ConfigFile), cfg) } -// ReadTickets reads tickets.yaml. +// ReadTickets reads all tickets from the tickets/ directory. func (s *Store) ReadTickets() ([]model.Ticket, error) { - var tf model.TicketFile - if err := readYAML(filepath.Join(s.TodoPath(), TicketsFile), &tf); err != nil { + entries, err := os.ReadDir(s.ticketsPath()) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { return nil, fmt.Errorf("read tickets: %w", err) } - return tf.Tickets, nil + var tickets []model.Ticket + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + var t model.Ticket + if err := readYAML(filepath.Join(s.ticketsPath(), e.Name()), &t); err != nil { + return nil, fmt.Errorf("read ticket %s: %w", e.Name(), err) + } + tickets = append(tickets, t) + } + return tickets, nil +} + +// WriteTicket writes a single ticket to tickets/.yaml. +func (s *Store) WriteTicket(t model.Ticket) error { + if err := os.MkdirAll(s.ticketsPath(), 0755); err != nil { + return err + } + return writeYAML(filepath.Join(s.ticketsPath(), t.ID+".yaml"), t) } -// WriteTickets writes tickets.yaml. -func (s *Store) WriteTickets(tickets []model.Ticket) error { - return writeYAML(filepath.Join(s.TodoPath(), TicketsFile), model.TicketFile{Tickets: tickets}) +// DeleteTicketFile removes tickets/.yaml. +func (s *Store) DeleteTicketFile(id string) error { + return os.Remove(filepath.Join(s.ticketsPath(), id+".yaml")) } -// ReadDeleted reads deleted.yaml. +// ReadDeleted reads all tickets from the deleted/ directory. func (s *Store) ReadDeleted() ([]model.Ticket, error) { - var tf model.TicketFile - p := filepath.Join(s.TodoPath(), DeletedFile) - if _, err := os.Stat(p); os.IsNotExist(err) { + entries, err := os.ReadDir(s.deletedPath()) + if os.IsNotExist(err) { return nil, nil } - if err := readYAML(p, &tf); err != nil { + if err != nil { return nil, fmt.Errorf("read deleted: %w", err) } - return tf.Tickets, nil + var tickets []model.Ticket + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + var t model.Ticket + if err := readYAML(filepath.Join(s.deletedPath(), e.Name()), &t); err != nil { + return nil, fmt.Errorf("read deleted ticket %s: %w", e.Name(), err) + } + tickets = append(tickets, t) + } + return tickets, nil } -// WriteDeleted writes deleted.yaml. -func (s *Store) WriteDeleted(tickets []model.Ticket) error { - return writeYAML(filepath.Join(s.TodoPath(), DeletedFile), model.TicketFile{Tickets: tickets}) +// WriteDeletedTicket writes a ticket to deleted/.yaml. +func (s *Store) WriteDeletedTicket(t model.Ticket) error { + if err := os.MkdirAll(s.deletedPath(), 0755); err != nil { + return err + } + return writeYAML(filepath.Join(s.deletedPath(), t.ID+".yaml"), t) } // ReadLabels reads labels.yaml (the set of known labels for autocomplete). @@ -125,23 +164,13 @@ func (s *Store) WriteLabels(labels []string) error { return writeYAML(filepath.Join(s.TodoPath(), LabelsFile), labelsIndex{Labels: labels}) } -// NextID returns the next unique ticket ID (max ever created + 1). -func (s *Store) NextID() (int, error) { - active, err := s.ReadTickets() - if err != nil { - return 0, err - } - deleted, err := s.ReadDeleted() - if err != nil { - return 0, err - } - max := 0 - for _, t := range append(active, deleted...) { - if t.ID > max { - max = t.ID - } +// NewID returns a random 8-character lowercase hex string. +func NewID() (string, error) { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return "", err } - return max + 1, nil + return fmt.Sprintf("%08x", b), nil } // SortedStatuses returns statuses ordered by their Order field. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a35546d..5e76da7 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -3,6 +3,7 @@ package store_test import ( "os" "path/filepath" + "regexp" "testing" "time" @@ -23,7 +24,7 @@ func TestInitialised_False(t *testing.T) { } } -func TestInit_CreatesFiles(t *testing.T) { +func TestInit_CreatesDirectories(t *testing.T) { s := newTempStore(t) cfg := model.DefaultConfig() if err := s.Init(cfg); err != nil { @@ -37,10 +38,15 @@ func TestInit_CreatesFiles(t *testing.T) { if _, err := os.Stat(configPath); err != nil { t.Errorf("config.yaml not found: %v", err) } - // tickets.yaml must exist - ticketsPath := filepath.Join(s.TodoPath(), "tickets.yaml") + // tickets/ directory must exist + ticketsPath := filepath.Join(s.TodoPath(), "tickets") if _, err := os.Stat(ticketsPath); err != nil { - t.Errorf("tickets.yaml not found: %v", err) + t.Errorf("tickets/ dir not found: %v", err) + } + // deleted/ directory must exist + deletedPath := filepath.Join(s.TodoPath(), "deleted") + if _, err := os.Stat(deletedPath); err != nil { + t.Errorf("deleted/ dir not found: %v", err) } } @@ -75,11 +81,13 @@ func TestWriteReadTickets(t *testing.T) { t.Fatal(err) } tickets := []model.Ticket{ - {ID: 1, Title: "First ticket", Status: "To Do", CreatedAt: time.Now().UTC()}, - {ID: 2, Title: "Second ticket", Status: "In Progress", CreatedAt: time.Now().UTC()}, + {ID: "abc00001", Title: "First ticket", Status: "To Do", CreatedAt: time.Now().UTC()}, + {ID: "abc00002", Title: "Second ticket", Status: "In Progress", CreatedAt: time.Now().UTC()}, } - if err := s.WriteTickets(tickets); err != nil { - t.Fatalf("WriteTickets: %v", err) + for _, tk := range tickets { + if err := s.WriteTicket(tk); err != nil { + t.Fatalf("WriteTicket: %v", err) + } } got, err := s.ReadTickets() if err != nil { @@ -88,69 +96,73 @@ func TestWriteReadTickets(t *testing.T) { if len(got) != len(tickets) { t.Fatalf("expected %d tickets, got %d", len(tickets), len(got)) } - for i, tk := range got { - if tk.ID != tickets[i].ID { - t.Errorf("ticket %d: ID mismatch: want %d, got %d", i, tickets[i].ID, tk.ID) + byID := map[string]model.Ticket{} + for _, tk := range got { + byID[tk.ID] = tk + } + for _, want := range tickets { + got, ok := byID[want.ID] + if !ok { + t.Errorf("ticket %s not found", want.ID) + continue } - if tk.Title != tickets[i].Title { - t.Errorf("ticket %d: Title mismatch: want %q, got %q", i, tickets[i].Title, tk.Title) + if got.Title != want.Title { + t.Errorf("ticket %s: Title mismatch: want %q, got %q", want.ID, want.Title, got.Title) } } } -func TestNextID_Empty(t *testing.T) { +func TestDeleteTicketFile(t *testing.T) { s := newTempStore(t) if err := s.Init(model.DefaultConfig()); err != nil { t.Fatal(err) } - id, err := s.NextID() + tk := model.Ticket{ID: "abc00001", Title: "task", Status: "To Do", CreatedAt: time.Now().UTC()} + if err := s.WriteTicket(tk); err != nil { + t.Fatalf("WriteTicket: %v", err) + } + if err := s.DeleteTicketFile(tk.ID); err != nil { + t.Fatalf("DeleteTicketFile: %v", err) + } + got, err := s.ReadTickets() if err != nil { - t.Fatalf("NextID: %v", err) + t.Fatal(err) } - if id != 1 { - t.Errorf("expected id 1, got %d", id) + if len(got) != 0 { + t.Errorf("expected 0 tickets after delete, got %d", len(got)) } } -func TestNextID_AfterTickets(t *testing.T) { +func TestReadTickets_EmptyDir(t *testing.T) { s := newTempStore(t) if err := s.Init(model.DefaultConfig()); err != nil { t.Fatal(err) } - tickets := []model.Ticket{ - {ID: 1, Title: "a", Status: "To Do"}, - {ID: 3, Title: "b", Status: "Done"}, - } - if err := s.WriteTickets(tickets); err != nil { - t.Fatal(err) - } - id, err := s.NextID() + got, err := s.ReadTickets() if err != nil { - t.Fatalf("NextID: %v", err) + t.Fatalf("ReadTickets on empty dir: %v", err) } - if id != 4 { - t.Errorf("expected id 4, got %d", id) + if len(got) != 0 { + t.Errorf("expected 0 tickets, got %d", len(got)) } } -func TestNextID_NeverReusesDeleted(t *testing.T) { - s := newTempStore(t) - if err := s.Init(model.DefaultConfig()); err != nil { - t.Fatal(err) +func TestNewID(t *testing.T) { + hexRe := regexp.MustCompile(`^[0-9a-f]{8}$`) + id, err := store.NewID() + if err != nil { + t.Fatalf("NewID: %v", err) } - // Active has id 1, deleted has id 5. - if err := s.WriteTickets([]model.Ticket{{ID: 1, Title: "a", Status: "To Do"}}); err != nil { - t.Fatal(err) + if !hexRe.MatchString(id) { + t.Errorf("NewID returned %q, want 8 lowercase hex chars", id) } - if err := s.WriteDeleted([]model.Ticket{{ID: 5, Title: "deleted", Status: "Done"}}); err != nil { - t.Fatal(err) - } - id, err := s.NextID() + // Two calls should produce different IDs. + id2, err := store.NewID() if err != nil { - t.Fatal(err) + t.Fatalf("NewID (second call): %v", err) } - if id != 6 { - t.Errorf("expected id 6 (max of active+deleted+1), got %d", id) + if id == id2 { + t.Error("two NewID calls returned the same value") } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1c19382..31e0518 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -54,7 +54,7 @@ var configFields = []cfgFieldDef{ type Model struct { store *store.Store cfg model.Config - tickets []model.Ticket // runtime (branch overrides applied) + tickets []model.Ticket statuses []model.Status // sorted colIdx int // focused column colOffset int // first visible column (horizontal scroll) @@ -71,8 +71,8 @@ type Model struct { inputBuf string inputCursor int // cursor position in runes within inputBuf err error - branch string - projectDir string + branch string + projectDir string width int height int knownLabels []string // all labels seen, persisted for autocomplete @@ -81,7 +81,7 @@ type Model struct { acIdx int // selected suggestion index (-1 = none) } -// New creates a TUI model for the given store, applying branch overrides. +// New creates a TUI model for the given store. func New(s *store.Store, projectDir string) (Model, error) { cfg, err := s.ReadConfig() if err != nil { @@ -93,13 +93,6 @@ func New(s *store.Store, projectDir string) (Model, error) { } branch := internalgit.CurrentBranch(projectDir) - if branch != "" && !internalgit.IsMainBranch(branch) { - gs, err := store.ReadGlobalState() - if err == nil { - overrides := store.GetBranchOverrides(gs, projectDir, branch) - tickets = store.ApplyBranchOverrides(tickets, overrides) - } - } knownLabels, _ := s.ReadLabels() if len(knownLabels) == 0 { @@ -687,7 +680,7 @@ func (m Model) autoCommitCmd(ticket model.Ticket, targetStatus string) tea.Cmd { if gitRoot == "" { return nil } - commitMsg := fmt.Sprintf("[%d] %s", ticket.ID, ticket.Title) + commitMsg := fmt.Sprintf("[%s] %s", ticket.ID, ticket.Title) script := ` cd "$GIT_ROOT" @@ -849,7 +842,7 @@ func (m Model) handleFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // --- data mutations --- func (m *Model) addTicket(title string) { - id, err := m.store.NextID() + id, err := store.NewID() if err != nil { m.err = err return @@ -862,7 +855,7 @@ func (m *Model) addTicket(title string) { CreatedAt: time.Now().UTC(), } m.tickets = append(m.tickets, t) - m.persist() + _ = m.store.WriteTicket(t) } func (m *Model) editTicket(title string) { @@ -874,10 +867,10 @@ func (m *Model) editTicket(title string) { for i, t := range m.tickets { if t.ID == selected.ID { m.tickets[i].Title = title + _ = m.store.WriteTicket(m.tickets[i]) break } } - m.persist() } func (m *Model) moveSelected(targetStatus string) { @@ -891,22 +884,10 @@ func (m *Model) moveSelected(targetStatus string) { tr := model.Transition{From: t.Status, To: targetStatus, At: time.Now().UTC()} m.tickets[i].Status = targetStatus m.tickets[i].Transitions = append(m.tickets[i].Transitions, tr) + _ = m.store.WriteTicket(m.tickets[i]) break } } - - // Branch tracking. - branch := internalgit.CurrentBranch(m.projectDir) - if branch != "" && !internalgit.IsMainBranch(branch) { - gs, err := store.ReadGlobalState() - if err == nil { - store.SetBranchOverride(&gs, m.projectDir, branch, selected.ID, targetStatus) - _ = store.WriteGlobalState(gs) - } - // Don't write to tickets.yaml on non-main branches. - return - } - m.persist() } func (m *Model) deleteSelected() { @@ -915,9 +896,8 @@ func (m *Model) deleteSelected() { return } selected := col[m.rowIdx] - deleted, _ := m.store.ReadDeleted() - deleted = append(deleted, selected) - _ = m.store.WriteDeleted(deleted) + _ = m.store.WriteDeletedTicket(selected) + _ = m.store.DeleteTicketFile(selected.ID) newTickets := make([]model.Ticket, 0, len(m.tickets)-1) for _, t := range m.tickets { @@ -929,15 +909,9 @@ func (m *Model) deleteSelected() { if m.rowIdx > 0 { m.rowIdx-- } - m.persist() -} - -func (m *Model) persist() { - // Only write tickets that don't have branch overrides pending. - _ = m.store.WriteTickets(m.tickets) } -func (m *Model) addLabelToTicket(ticketID int, label string) { +func (m *Model) addLabelToTicket(ticketID string, label string) { for i, t := range m.tickets { if t.ID == ticketID { for _, l := range t.Labels { @@ -946,14 +920,14 @@ func (m *Model) addLabelToTicket(ticketID int, label string) { } } m.tickets[i].Labels = append(m.tickets[i].Labels, label) + _ = m.store.WriteTicket(m.tickets[i]) break } } m.updateKnownLabels(label) - m.persist() } -func (m *Model) removeLabelFromTicket(ticketID int, label string) { +func (m *Model) removeLabelFromTicket(ticketID string, label string) { for i, t := range m.tickets { if t.ID == ticketID { out := make([]string, 0, len(t.Labels)) @@ -963,10 +937,10 @@ func (m *Model) removeLabelFromTicket(ticketID int, label string) { } } m.tickets[i].Labels = out + _ = m.store.WriteTicket(m.tickets[i]) break } } - m.persist() } func (m *Model) updateKnownLabels(label string) { @@ -1012,10 +986,10 @@ func (m *Model) updateStatusName(idx int, name string) { for k := range m.tickets { if m.tickets[k].Status == old { m.tickets[k].Status = name + _ = m.store.WriteTicket(m.tickets[k]) } } m.statuses = store.SortedStatuses(m.cfg.Statuses) - m.persist() } func (m *Model) updateStatusColor(idx int, color string) { @@ -1138,7 +1112,7 @@ func (m *Model) clampScroll() { used := 0 for ti := scroll; ti <= m.rowIdx && ti < len(tickets); ti++ { t := tickets[ti] - label := fmt.Sprintf("[%d] %s", t.ID, t.Title) + label := fmt.Sprintf("[%s] %s", t.ID, t.Title) used += len(wrapText(label, approxTextW)) + len(chipRows(t.Labels, approxTextW)) if ti < len(tickets)-1 { used++ // separator @@ -1149,7 +1123,7 @@ func (m *Model) clampScroll() { for used > vis && scroll < m.rowIdx { // Subtract the height of the ticket leaving the top. t := tickets[scroll] - label := fmt.Sprintf("[%d] %s", t.ID, t.Title) + label := fmt.Sprintf("[%s] %s", t.ID, t.Title) used -= len(wrapText(label, approxTextW)) + len(chipRows(t.Labels, approxTextW)) if scroll < len(tickets)-1 { used-- // separator that was after this ticket @@ -1170,7 +1144,7 @@ func (m Model) ticketsInCol(statusName string) []model.Ticket { } result = append(result, t) } - sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt.Before(result[j].CreatedAt) }) return result } @@ -1315,7 +1289,7 @@ func (m Model) viewBoard() string { // ticketHeight returns the number of display rows a ticket occupies. ticketHeight := func(t model.Ticket, tw int) int { - label := fmt.Sprintf("[%d] %s", t.ID, t.Title) + label := fmt.Sprintf("[%s] %s", t.ID, t.Title) return len(wrapText(label, tw)) + len(chipRows(t.Labels, tw)) } @@ -1392,7 +1366,7 @@ func (m Model) viewBoard() string { drows := make([]drow, 0, ticketRows) for ti := scrollOffset; ti < len(tickets) && len(drows) < ticketRows; ti++ { t := tickets[ti] - label := fmt.Sprintf("[%d] %s", t.ID, t.Title) + label := fmt.Sprintf("[%s] %s", t.ID, t.Title) for li, line := range wrapText(label, textW) { if len(drows) >= ticketRows { break @@ -1713,7 +1687,7 @@ func (m Model) viewDetail() string { panelHints([][2]string{{"Esc", "close"}}), "", ) - return m.centeredOverlay(formPanel(fmt.Sprintf("Ticket #%d", t.ID), rows, innerW)) + return m.centeredOverlay(formPanel(fmt.Sprintf("Ticket #%s", t.ID), rows, innerW)) } func (m Model) viewMoveMenu() string { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index fcf03e8..1ab2801 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -17,19 +17,24 @@ func setupModel(t *testing.T) tui.Model { if err := s.Init(model.DefaultConfig()); err != nil { t.Fatal(err) } + now := time.Now() tickets := []model.Ticket{ - {ID: 1, Title: "First task", Status: "To Do", CreatedAt: time.Now()}, - {ID: 2, Title: "WIP task", Status: "In Progress", CreatedAt: time.Now()}, - {ID: 3, Title: "Completed task", Status: "Done", CreatedAt: time.Now()}, + {ID: "00000001", Title: "First task", Status: "To Do", CreatedAt: now}, + {ID: "00000002", Title: "WIP task", Status: "In Progress", CreatedAt: now.Add(time.Second)}, + {ID: "00000003", Title: "Completed task", Status: "Done", CreatedAt: now.Add(2 * time.Second)}, } - if err := s.WriteTickets(tickets); err != nil { - t.Fatal(err) + for _, tk := range tickets { + if err := s.WriteTicket(tk); err != nil { + t.Fatal(err) + } } m, err := tui.New(s, dir) if err != nil { t.Fatalf("New: %v", err) } - return m + // Use a wide terminal so ticket labels don't wrap and split title strings. + result, _ := m.Update(tea.WindowSizeMsg{Width: 200, Height: 40}) + return result.(tui.Model) } func sendKey(m tui.Model, key string) tui.Model { diff --git a/resources/gen-sample.sh b/resources/gen-sample.sh index faaeede..d45b5f6 100755 --- a/resources/gen-sample.sh +++ b/resources/gen-sample.sh @@ -280,24 +280,27 @@ for i in "${!STATUSES[@]}"; do done POOL_SIZE=${#STATUS_POOL[@]} -# Emit tickets.yaml -{ - echo "tickets:" - now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - for (( id=1; id<=1000; id++ )); do - adj="${ADJECTIVES[$(( (id * 7 + 3) % ${#ADJECTIVES[@]} ))]}" - noun="${NOUNS[$(( (id * 13 + 5) % ${#NOUNS[@]} ))]}" - title="${adj} ${noun}" - status="${STATUS_POOL[$(( (id * 31 + 11) % POOL_SIZE ))]}" - - # Stagger created_at dates over the past year - days_ago=$(( id % 365 )) - created=$(date -u -v-"${days_ago}d" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \ - || date -u -d "${days_ago} days ago" +"%Y-%m-%dT%H:%M:%SZ") - - echo " - id: ${id}" - echo " title: \"${title}\"" - echo " status: \"${status}\"" +# Emit one YAML file per ticket into .todo/tickets/ +echo "→ Generating 1 000 ticket files..." +mkdir -p .todo/tickets .todo/deleted + +now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +for (( id=1; id<=1000; id++ )); do + ticket_id=$(printf "%08x" "$id") + adj="${ADJECTIVES[$(( (id * 7 + 3) % ${#ADJECTIVES[@]} ))]}" + noun="${NOUNS[$(( (id * 13 + 5) % ${#NOUNS[@]} ))]}" + title="${adj} ${noun}" + status="${STATUS_POOL[$(( (id * 31 + 11) % POOL_SIZE ))]}" + + # Stagger created_at dates over the past year + days_ago=$(( id % 365 )) + created=$(date -u -v-"${days_ago}d" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \ + || date -u -d "${days_ago} days ago" +"%Y-%m-%dT%H:%M:%SZ") + + { + echo "id: ${ticket_id}" + echo "title: \"${title}\"" + echo "status: \"${status}\"" # Assign 0–2 labels deterministically (≈30% none, 40% one, 30% two) lc=$(( (id * 17 + 3) % 10 )) @@ -314,25 +317,25 @@ POOL_SIZE=${#STATUS_POOL[@]} lbl2="${LABELS[$li2]}" fi if (( lc == 1 )); then - echo " labels:" - echo " - ${lbl1}" + echo "labels:" + echo " - ${lbl1}" elif (( lc == 2 )); then - echo " labels:" - echo " - ${lbl1}" - echo " - ${lbl2}" + echo "labels:" + echo " - ${lbl1}" + echo " - ${lbl2}" fi - echo " created_at: ${created}" + echo "created_at: ${created}" # Add a transition for anything not in To Do if [ "$status" != "To Do" ]; then - echo " transitions:" - echo " - from: To Do" - echo " to: \"${status}\"" - echo " at: ${created}" + echo "transitions:" + echo " - from: To Do" + echo " to: \"${status}\"" + echo " at: ${created}" fi - done -} > .todo/tickets.yaml + } > ".todo/tickets/${ticket_id}.yaml" +done # Write the autocomplete index — all labels from the pool, sorted echo "→ Writing .todo/labels.yaml..."