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
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -87,15 +87,16 @@ 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/<id>.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

### 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
Expand Down
28 changes: 11 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}

Expand Down
18 changes: 3 additions & 15 deletions cmd/transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cmd
import (
"fmt"
"os"
"strconv"
"time"

"github.com/frankcruz/tasklin/internal/model"
Expand All @@ -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()
Expand All @@ -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() {
Expand Down
88 changes: 39 additions & 49 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.yaml │ │
│ │ deleted/<id>.yaml │ │
│ └───────────────────────┘ │
└───────────────────────────────────────────────────────────┘
```

Expand All @@ -67,48 +66,50 @@ 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 <id> <status>` — internal command used by git hooks only |

### `internal/model/`

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/<id>.yaml`
- `Store.DeleteTicketFile(id)` — removes `tickets/<id>.yaml`
- `Store.WriteDeletedTicket(t)` — writes a ticket to `deleted/<id>.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/`

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/`

Expand All @@ -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`)

---

Expand All @@ -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()
```

Expand All @@ -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
Expand All @@ -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/<id>.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.

---

Expand All @@ -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 |
Loading
Loading