A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at noah/git-backend-2 211 lines 9.4 kB view raw view rendered
1# Architecture 2 3`tsk` is a task tracker that stores everything in git refs under 4`refs/tsk/*` of an enclosing repository. There are no working-tree 5files — pushing the host repo's refs carries the task state along 6with it. This document is a tour of the moving pieces; the user- 7facing crib sheet lives in [AGENTS.md](AGENTS.md). 8 9## On-disk layout 10 11Everything lives inside the host repo's `.git` directory. Two 12classes: 13 14- **Shared, pushed**: `refs/tsk/*` — the actual task data. Visible 15 to every clone via standard `git push` / `git fetch` once the 16 refspec is set up. 17- **Per-clone, not pushed**: `<git-dir>/tsk/{namespace,queue}` 18 two tiny text files holding the active selectors. Each clone 19 picks its own. 20 21``` 22<git-dir>/ 23├── refs/ 24│ └── tsk/ 25│ ├── tasks/<stable-id> # one ref per task; commit chain = task history 26│ ├── namespaces/<name> # tree: { next, ids/<human-id> → <stable-id> } 27│ ├── queues/<name> # tree: { index, can-pull, inbox/<key> → <stable-id> } 28│ └── properties/<key> # tree: { <stable-id> → blob of values } 29└── tsk/ 30 ├── namespace # active namespace (defaults to "tsk") 31 ├── queue # active queue (defaults to "tsk") 32 └── remote # default git remote for sync (defaults to "origin") 33``` 34 35Every category except `tasks` is a tree; per-task histories are 36their own commit chain so each task's edit log stands on its own. 37 38## Identifiers 39 40Two layers of id, related but distinct: 41 42- **Stable id** = SHA-1 of the task's initial `content` blob. Same 43 body → same id, on every clone, forever. Used as the ref name 44 (`refs/tsk/tasks/<stable-id>`) and as the cross-namespace handle. 45- **Human id** = `tsk-<N>`. A small integer minted per-namespace by 46 the namespace's `next` counter. Always namespace-scoped: `tsk-5` 47 in namespace `alpha` is unrelated to `tsk-5` in namespace `tsk`. 48 49A namespace is essentially `human → stable` plus the `next` counter. 50 51``` 52namespace tsk namespace claude 53┌─ tsk-1 → 8f77… ┐ ┌─ tsk-1 → 5c43… ┐ 54├─ tsk-2 → 2a09… │ ├─ tsk-2 → 8f77… │ ← same stable, different humans 55├─ tsk-3 → 0a2d… │ └────────────────┘ 56└─ next: 4 ┘ 57``` 58 59## Module map 60 61```mermaid 62graph TD 63 bin[bin/tsk + bin/git-tsk] --> lib[lib.rs<br/>CLI dispatch] 64 lib --> workspace 65 lib --> task[task.rs<br/>rich-text parser] 66 lib --> fzf[fzf.rs<br/>picker wrapper] 67 lib --> errors 68 69 workspace --> object[object.rs<br/>task tree<br/>+ shared signature()] 70 workspace --> namespace[namespace.rs<br/>human ↔ stable] 71 workspace --> queue[queue.rs<br/>index + inbox] 72 workspace --> properties[properties.rs<br/>per-key indices] 73 workspace --> patch[patch.rs<br/>mbox export/import] 74 workspace --> merge[merge.rs<br/>3-way reconcile] 75 76 object --> propvalue[propvalue.rs<br/>length-prefix codec] 77 properties --> propvalue 78 merge --> namespace 79 merge --> queue 80 merge --> object 81 patch --> object 82``` 83 84`object.rs` is the choke point for git plumbing — every other 85storage module reuses its `signature()` helper so all writes carry 86the same author/committer. 87 88## Task lifecycle 89 90``` 91 tsk push tsk drop tsk reopen 92no refs ──► open ─────────────────► done ─────────────────► open 93 │ │ │ 94 │ tsk push <same body> │ │ 95 └──────────────────────┘ (reopen-on-duplicate) 96``` 97 98`status` is an auto-managed property: `open` on creation, flipped to 99`done` on `tsk drop`. The task ref's commit chain is **never** 100deleted — `done` is just a property and the binding stays addressable 101(`tsk show -T tsk-N` keeps working). `tsk reopen` flips status back 102and re-pushes onto the active queue. 103 104Pushing a body whose stable id already exists short-circuits to a 105reopen instead of clobbering — see `Workspace::new_task`. 106 107## Operation flow: `tsk push "..."` 108 109``` 110new_task(title, body) 111112 ▼ ① compute stable id (= SHA of content blob) 113 ▼ ② object::create → refs/tsk/tasks/<sha> 114 ▼ ③ properties::reindex_task → refs/tsk/properties/status 115 ▼ ④ namespace::assign_id → refs/tsk/namespaces/<active> 116push_task(task) 117 ▼ ⑤ queue::push_top → refs/tsk/queues/<active> 118``` 119 120The order matters: a crash between any two steps leaves recoverable 121state because the most-derived ref (queue) is written last. 122`tsk fix-up` (see `Workspace::gc_refs`) is the idempotent cleanup 123pass that drops orphan property entries, ghost namespace bindings, 124empty queues, and dangling queue index entries. 125 126## Sync flow: `tsk git-pull` 127 128```mermaid 129sequenceDiagram 130 participant Local 131 participant Remote 132 133 Local->>Remote: git fetch --refmap= +refs/tsk/*:refs/tsk-fetched/<remote>/* 134 Note over Local: local refs/tsk/* untouched 135 136 loop tasks 137 Local->>Local: reconcile_task_refs<br/>(merge or rebase strategy) 138 end 139 loop namespaces 140 Local->>Local: reconcile_namespace_refs<br/>(remote-wins; auto-renumber on collision) 141 end 142 loop queues 143 Local->>Local: reconcile_queue_refs<br/>(3-way set/map merge) 144 end 145 Local->>Local: fast_forward_non_task_refs<br/>(properties, etc.) 146``` 147 148Why the shadow `refs/tsk-fetched/<remote>/*` namespace? So the local 149`refs/tsk/*` aren't clobbered before the reconcile pass gets a 150chance to run. `--refmap=` disables the configured fetch refspec so 151git doesn't sneakily perform the default mapping behind our back. 152 153Reconciliation per ref-class: 154 155| Ref class | Strategy | 156| -------------- | ---------------------------------------------------------------- | 157| `tasks/*` | git2 `merge_trees` 3-way; clean → merge commit, conflict → abort that task. `--rebase` replays local commits onto remote tip preserving authorship. | 158| `namespaces/*` | data-aware: remote wins on `human → stable` collision, local binding renumbered to a fresh id past `max(local.next, remote.next)`. | 159| `queues/*` | per-stable 3-way set merge for `index`, per-key 3-way map merge for `inbox`, 3-way bool for `can_pull`. Empty base when no common ancestor. | 160| everything else (`properties/*`) | force-update from fetched (concurrent edits on the same key/task are rare and `tsk fix-up` reindexes anyway). | 161 162## Wire formats 163 164Two ways to ship state between repos: 165 166- **Standard git transport** (`tsk git-push` / `tsk git-pull`, 167 configured via `tsk git-setup`). Carries every `refs/tsk/*` 168 alongside ordinary branches. 169- **mbox patch series** (`tsk export` / `tsk import`). Each task 170 commit becomes one mbox entry: standard `From <sha>` separator, 171 RFC-822 headers including `X-Tsk-Stable-Id`, `X-Tsk-Parent`, 172 and an optional `X-Tsk-Namespace` binding hint, then a 173 size-prefixed dump of every file in the task tree between 174 `---tsk-tree---` and `---end---`. Survives strict mbox parsers 175 via standard mboxrd `From `-mangling. Stable id is verified on 176 import — tampered patches are rejected. 177 178## Per-clone overrides 179 180The active selectors live in `<git-dir>/tsk/{namespace,queue}`. Two 181ways to override per-invocation: 182 183- `tsk -q <queue> <command>` — global flag. A `OnceLock` set in 184 `dispatch` is consulted by `Workspace::queue()` before falling 185 back to the file. 186- `tsk switch <ns>` / `tsk queue switch <q>` — persists by 187 rewriting the file. 188 189## Property values 190 191Each property is a list of bytes-strings. Storage is length-prefixed 192blocks (`size: N\n<N bytes>\n`) inside the per-key blob, mirroring 193the patch wire format. This means values can contain anything, 194including newlines. The reader still tolerates the legacy 195line-split format and `tsk fix-up` migrates blobs on next save. 196 197## Reconciliation matrix 198 199| Drift class | Detected by | Fixed by | 200| ------------------------------------------------------ | ------------------- | --------------------------- | 201| Empty non-default queue | `gc_refs` | delete the ref | 202| Property index entry → missing task ref | `gc_refs` | `properties::set(empty)` | 203| Namespace binding → missing task ref ("ghost") | `gc_refs` | rewrite namespace tree | 204| Queue index entry → missing task ref ("orphan") | `gc_refs` | rewrite queue tree | 205| Two clones diverged on the same task ref | `git-pull` (tasks) | merge or rebase | 206| Two clones allocated the same human id | `git-pull` (ns) | auto-renumber local | 207| Two clones edited the same queue concurrently | `git-pull` (queues) | 3-way set/map merge | 208| Property blob in legacy line-split format | `tsk fix-up` | re-save with new codec | 209 210`tsk fix-up` is the umbrella command that runs every one-shot 211migration plus `gc_refs`; safe to run repeatedly.