Storage contract
Normative, as-built. Describes where yman keeps data and what shape it is in.
Companion documents: commands.md, errors.md.
1. Names and paths
| Term | Meaning | Source |
|---|---|---|
ROOT | main repo toplevel | git rev-parse --path-format=absolute --show-toplevel --git-common-dir, first line |
COMMON | main repo common git dir, absolute | the same call, second line |
YDIR | ROOT/.yman | |
WT_GITDIR | private git dir of the .yman worktree | read from YDIR/.git |
LOCAL | refs/yman/local — task history head | |
REMOTE | refs/yman/remote — remote-tracking ref | |
REMOTE_REF | refs/tasks/main — the ref name on origin |
Two git runners exist, both over the same object store and refs
(src/repo.rs):
- in main —
git -C ROOT …: refs, fetch, push, config, worktree management. - in wt —
git -C YDIR …: anything touching task files — add, mv, rm, commit, merge, status, diff, log.
WT_GITDIR is read, never constructed
Git derives a linked worktree’s private directory name from the basename and
sanitizes it, so .yman becomes COMMON/worktrees/-yman — with a leading
hyphen, not the literal name. repo::read_gitdir_file follows the gitdir:
line in YDIR/.git to the real path; ydir_state then classifies .yman as a
worktree only when that path lives under COMMON/worktrees/ and contains a
gitdir file. Building the path from the directory name breaks every command
with .yman is not initialized.
2. The ref namespace
Task commits live on LOCAL, outside refs/heads/. .yman is a linked
worktree whose HEAD is the symbolic ref refs/yman/local, created as:
git worktree add --detach .yman refs/yman/local
git -C .yman symbolic-ref HEAD refs/yman/local
scripts/spike-symref.sh exercises the properties this depends on, and is
re-runnable:
git commitinside the worktree movesrefs/yman/local, and HEAD stays symbolic. Noupdate-reffixup is needed. Enforced: the script fails when the ref did not advance, when it disagrees with the worktree’s HEAD, and when HEAD is no longer the symbolic refrefs/yman/local.git branch -anever mentions the ref. Enforced.git merge --ff-onlyworks against a ref built withcommit-tree. Enforced.- The main worktree’s
git statusstays clean. Enforced.
The consequence for everything else: git status, git log, git diff, branch
pickers and CI never see task history, while git log --all, git show-ref and
git worktree list do.
Only one .yman per clone is supported. The worktree is added with
--detach, and git only guards branches against a second checkout, so init
does not rely on git refusing: before creating .yman it reads
git worktree list --porcelain, and when any worktree is on
branch refs/yman/local it stops with refs/yman/local is already checked out in another worktree of this repo; …. That is the case from a linked worktree
of the project, whose toplevel differs from the main one.
Discovery runs rev-parse --show-toplevel, which inside .yman answers
.yman itself. When that toplevel is named .yman and its .git file points
under COMMON/worktrees/, the project root is taken to be its parent, so
commands run from a task folder work and init there takes the repair path
rather than nesting a second worktree.
Refs are read from disk, with git as the fallback
Resolving a ref is a file read, and yman does it as one (src/refs.rs):
COMMON/<ref> first, then a scan of COMMON/packed-refs, chasing a ref:
line up to five hops. WT_GITDIR/HEAD is read the same way for the symbolic
HEAD check. Both files are replaced by rename, so a concurrent update is read
as either the old value or the new one, never a torn one, and a loose ref takes
precedence over the packed copy exactly as it does for git.
Anything the reader does not recognise — the reftable backend (git 2.45+,
detected as COMMON/reftable/), an unreadable file, a value that is not a full
hex object id — is not a failure: the caller falls back to git rev-parse or
git symbolic-ref, which is always authoritative. An unfamiliar repository is
therefore slower, never wrong. The write paths (sync, init, status) keep
asking git directly, because they read refs that git itself has just moved.
The visible consequence is the process count: yman ls and yman show in an
up-to-date repository spawn one git, the rev-parse above. A repository
holding unpushed commits spawns a second for the ancestry question, which no
file answers. tests/cli.rs pins both numbers.
3. Repository configuration
yman init writes exactly these, all idempotently:
| Location | Value | Purpose |
|---|---|---|
COMMON/info/exclude | .yman/ | keeps the code worktree’s git status clean |
remote.origin.fetch (appended) | +refs/tasks/main:refs/yman/remote | a plain git fetch carries task commits; only added when origin exists |
yman.refresh | lazy | manual | see commands.md §5 |
yman.autosync | off | push | only when --autosync is given; unset means off; see commands.md §1 |
yman.author | prefix string | only meaningful for the author id scheme |
merge.ymanmeta.name | yman m.yml field-wise merge | shown by git when the driver runs |
merge.ymanmeta.driver | '<yman>' merge-driver %O %A %B — the binary’s path single-quoted for sh, a ' in it written '\'' | see Merging m.yml |
.yman/.gitattributes | **/d.md merge=union, **/m.yml merge=ymanmeta | committed, so every clone inherits it |
remote.origin.push is never set: that would hijack the user’s plain
git push. Publishing is always an explicit refspec,
<sha>:refs/tasks/main with <sha> the commit refs/yman/local held when
the push began, pushed with --porcelain so that a push
origin rejected because the ref moved (retried) is told apart from one it
refused (not retried) by git’s per-ref line rather than by its stderr — see
commands.md step 8.
Every git that yman runs for itself gets -c core.quotePath=false and
-c color.ui=never on its command line and LC_ALL=C in its environment
(src/git.rs): several decisions read git’s own English messages —
couldn't find remote ref for an origin with no tasks yet, nothing to commit
for a change that is already in the tree — and a localized git would turn each
of them into a failure. The one exception is yman git, a raw passthrough that
keeps the user’s locale and flags.
The runner in wt adds -c core.hooksPath=/dev/null, and so does the single
git worktree add that creates YDIR: no hook file exists below a device
file, so the user’s post-commit, post-merge, post-checkout and post-rewrite
never run with YDIR as their working directory (--no-verify only covers
the pre-hooks). /dev/null rather than an empty directory under WT_GITDIR
because it needs no setup and works before WT_GITDIR exists. The runner in
main keeps the user’s hooks, including the ones yman hooks install writes.
When the repository has no origin and --remote <url> is given, init
creates the remote — fetch and push address origin by name, so reporting
the URL without creating it would lead nowhere.
4. Directory layout
ROOT/
.git/
config # + fetch refspec, yman.refresh, yman.autosync,
# yman.author, merge.ymanmeta.*
info/exclude # + ".yman/"
refs/yman/{local,remote} # remote only once fetched; absent when local-only
worktrees/-yman/ # git-managed; HEAD, index, MERGE_HEAD
hooks/{post-merge,post-checkout} # optional, `yman hooks install`;
# core.hooksPath instead when set
.yman/
.git # file: "gitdir: <COMMON>/worktrees/-yman"
.gitignore # *.swp *~ .#* *.orig
.gitattributes # **/d.md merge=union, **/m.yml merge=ymanmeta
config.toml
2.14.fix-login/
t.md
m.yml
d.md # only once commented
f/ # only once something is attached
screenshot.png
5. Task folder names
Grammar, anchored: ^([0-9])\.([^./\\]+)\.(.+)$ → priority: u8, id: String,
slug: String. Parsed by hand in task::FolderName::parse; there is no regex
dependency.
- Priority
0–9,0highest, so a plainls .yman/sorts urgent work first. Only the priority sorts numerically: the listing is lexical, so within one priority5.10.xcomes before5.2.x.yman lsorders ids properly (commands.md §3); zero-padding them here would change the id contract. idnever contains..FolderName::parsesplits at the first dot after the priority and only rejects/and\outright, so a dotted id would be silently mis-read rather than refused —5.v.i-1.slugparses as idvwith slugi-1.slug.seqandrandomcannot produce one, and theauthorprefix is validated before an id is minted (§8).- The folder name is the source of truth for priority and id.
m.ymldoes not repeat them, and changing either is agit mvso history follows. - Entries in
.yman/that are not directories, or whose names do not match, are ignored entirely (config.toml,.git, dotfiles) — except that a directory whose name is a legal status name is descended into, because closed tasks live one level down (below). - A directory that matches but fails to load is a broken task:
lsprints it with a!marker and the parse error; commands that target it fail. - Two folders with the same id is an error —
duplicate task id …— and can only result from a badly resolved merge. The message names paths relative to.yman, not folder names, because the two copies can differ only in which status directory they sit in.
Closed tasks live one level down
A task in a terminal status (§7) is stored at
.yman/{status}/{priority}.{id}.{slug}/ instead of at the top level. Only
version = 2 does this; a version 1 repository keeps every task flat.
m.ymlis the source of truth, not the path. A task physically underdone/whosem.ymlsaystodois listed astodo, and the nextsetmoves it where it belongs. This is what keeps a config change from bricking a repository, the same promise §6 makes about unknown status values.- The second level is found by grammar — any directory whose name is a legal
status name is descended into — not by consulting
statuses.terminal. A task archived under a status later removed from the list must still be found. - Nothing is nested deeper than one level.
slugify(title, max_bytes)
- Unicode lowercase.
- Alphanumerics (
char::is_alphanumeric) survive; every other character becomes a separator. - Separator runs collapse to one
-; leading and trailing-are trimmed. - Truncate to
max_bytesat a char boundary, then trim a trailing-again. - An empty result becomes
task.
Non-ASCII survives: "Первая задача" → первая-задача.
Case-insensitive and normalizing filesystems
Only Linux is tested. The guards in the code are for terminal statuses (two that differ only in ASCII case are rejected, §7, because they would be one directory) and attachment names (below). Everything else below is known to behave differently off Linux and is not checked:
- Case-insensitive filesystems (the macOS and Windows defaults). Slugs
cannot collide, because
slugifylowercases. Attachment names andauthorprefixes are not lowercased, though.attachcompares names ignoring case, so it never createsA.pngbesidea.png, andattach --force a.pngover an existingA.pngreplaces the file, the entry and the index path (the old spelling is dropped from the index first, so a case-only change reaches every clone). A hand edit or a merge can still bring two such files together. Two prefixes that differ only in case (IVandiv) mint ids that name distinct folders on Linux and can fold into one elsewhere. - Unicode normalization. HFS+ stores names in NFD; APFS, the macOS default
since 2017, keeps whatever form it is given. A Cyrillic slug minted on Linux
(NFC) and
git mv’d on an HFS+ volume can come back as a different byte sequence unlesscore.precomposeunicodeis set, which git on macOS enables by default in new repositories. - Windows names. A terminal status called
con,nul,auxor another reserved device name passes validation but cannot be a directory there, and so can an attachment of that name. Otherwiseattachrefuses what Windows cannot hold::,*,?,",<,>,|, control characters and a trailing.or space.
6. File formats
t.md
# Fix login
Free-form body. May be empty.
The title is the first non-blank line, which after trimming must be #,
whitespace, then a non-empty title (so # Title is accepted). Blank lines
before it are skipped; anything else non-blank is a parse error
(t.md must start with "# Title"). A leading UTF-8 BOM is skipped, here and
in m.yml. The body is everything after the title line with one leading
blank line (\n or \r\n) stripped. On write, leading and trailing newlines
of the body are dropped and exactly one \n ends the file; otherwise the body
is kept as is — #, --- and code fences inside it are preserved.
m.yml
status: doing
tags:
- auth
- bug
assignee: Ivan
created: 2026-09-16T10:00:00Z
updated: 2026-09-16T10:12:30Z
attachments:
- path: f/screenshot.png
name: screenshot.png
added: 2026-09-16T10:05:00Z
by: Ivan
links:
- https://example.com/issues/12
related:
- '5'
- Every field but
status,createdandupdateddefaults to empty. - Timestamps are RFC 3339, UTC, second precision (
task::now()zeroes the nanoseconds), serialized with aZsuffix. attachments[].pathis alwaysf/<name>.tagsentries written by yman are trimmed, lowercased and free of whitespace, commas and control characters (see commands.md §4). The reader enforces none of that: a hand-writtentags: [UI]loads as it stands, andls -tandyman tagsfold case so it still matches and still counts.- Unknown top-level keys survive a rewrite. The reader keeps the lines a key
owns verbatim and the writer replays them after
related, so a field a newer yman writes — or one added by hand — is not destroyed by an older binary. They are preserved, not interpreted: nothing reads them and none of them appear in--jsonoutput. Comments, and unknown keys inside anattachmentsitem, are still dropped. Trailing empty lines after an unknown block are dropped too, except under a keep header (|+,>+), where the empty lines directly after the value are part of it and kept, up to the first column-0 comment line. An indented#line inside the block is content and kept. m.ymlis read and written by hand insrc/yml.rs; there is no YAML crate. The writer emits exactly the shape above: block sequences at column zero,[]for an empty list,nullfor an absentassignee, and a scalar plain unless that would not read back as the same string. It is single-quoted when empty, when it would read back as a number, a boolean or null, when it has leading or trailing whitespace (any Unicode whitespace, such as U+00A0, since the reader trims all of it), starts with---or..., starts with a YAML indicator (#,[]{}&*!|>'"%@and the backtick, or-,?,:followed by a space or nothing), or contains:,#or a trailing:. It is double-quoted when it holds an unprintable character. A value containing newlines becomes a literal block (|-,|,|+), or a double-quoted scalar when a line ends in a space or tab, or holds nothing but whitespace (U+00A0 included), which a block would lose or read back empty. The preserved unknown blocks follow, which is why a hand-edited file that interleaved one comes back with it moved to the end.- The reader is deliberately wider than the writer, because people edit this
file and resolve merge conflicts in it: comments (after a quoted scalar
too,
status: 'todo' # x), flow sequences (tags: [a, b]), single- and double-quoted scalars with the full libyaml escape set (\a \b \v \f \N \_ \L \Pincluded), folded blocks (>-) and sequences indented under their key are all accepted. A key repeated at the top level or inside anattachmentsitem keeps its last occurrence, which is the half of a conflict a person usually means to keep. Anassigneethat is blank or-reads as absent, so a hand edit to''and the-an olderset -a -stored both mean unassigned. - A flow sequence is split on every
,before its items are unquoted, so a quoted item cannot contain a comma:tags: ['a,b']is rejected withunterminated single-quoted string: 'a(and["a,b"]with its double-quoted counterpart). The writer never produces flow form, so this only bites a hand edit; write such an item as a block sequence (- 'a,b') instead. - A
statusoutsideconfig.statuses.listis reported, never a hard error, so editing the config cannot brick existing tasks. An emptystatus— bare,null, or whitespace — is a different thing: it reads as a missing field, so the task is reported broken rather than loaded with no status at all.
Merging m.yml
.gitattributes marks **/m.yml as merge=ymanmeta, and yman init points
that name at yman merge-driver %O %A %B in the clone’s own config. The
attribute travels in the history; the merge.ymanmeta.* config does not, so
every clone registers it for itself. A clone that never did falls back to git’s
text merge, which is what m.yml got before the driver existed — an
unregistered driver cannot corrupt a file.
A stale one is different. The registered path is current_exe at the time
of init, and an upgrade can remove it (a Homebrew Cellar, a Nix store,
target/debug). git then cannot run the driver, records the conflict, and
leaves ours in the file with no markers. sync prints git’s own complaint and
lists the file as (no conflict markers: binary, modify/delete, or the merge driver failed), and --continue will not take it as resolved — see
commands.md §6. Rerunning yman init
registers the current binary again.
Why it exists: the writer emits keys in one fixed order, so a status change on
one clone and a tag change on another land on adjacent lines and the text
merge conflicts. updated makes it worse — it changes on every edit on both
sides, so it is a permanently-differing line wedged between created and
attachments, dragging its neighbours into conflicts they do not deserve.
The driver merges field by field:
| Field | Rule |
|---|---|
updated | the later of the two; never conflicts |
created | the earlier of the two; never conflicts |
status, assignee | the side that moved wins; both moved differently is a conflict |
tags, links, related, attachments, unknown blocks | merged entry by entry (below) |
A list is merged one entry at a time, so a tag added on each clone keeps both.
Each entry is the same three-way decision as a scalar, over “is it there, and
with what value”: added on one side stays, removed on one side goes, and only an
entry changed differently on both sides conflicts. Entries are identified by
themselves for tags, links and related, by path for attachments, and
by the top-level key for an unknown block — so two clones attaching different
files under one name, or writing the same unknown key with different text, do
conflict.
The result’s order is the one thing the driver cannot take from either side.
Git hands %A and %B over the other way round on the other clone, so anything
derived from “ours first” would give the two clones two orderings of one set,
and they would conflict on that at the next sync. So: entries that were already
in the merge base keep the base’s order, and everything new is appended sorted
by key. A merge can therefore reorder a task’s tags relative to the order the
user typed them in — only on a merge, and only among newly added entries. A
duplicate entry in a hand-edited list collapses to its first occurrence.
The driver is all-or-nothing. One conflicting field abandons the whole
field-wise merge and hands the three files to git merge-file, so what the user
resolves by hand is byte for byte what they would have seen without the driver:
<<<<<<< markers in m.yml, yman sync exiting 3, yman sync --continue
after. The two sides are labelled ours and theirs rather than HEAD and
the ref name: the driver is handed three temporary files and never learns what
is being merged into what. The same happens when any of the three versions does not parse — a
half-resolved file, or the empty %O git passes when the two sides share no
ancestor.
d.md
Append-only. One entry is exactly:
## 2026-09-16T10:10:00Z — Ivan
comment text, possibly several lines
i.e. ## {rfc3339} — {author}, blank line, text, blank line. The separator is
an em dash.
A new entry starts only at a line of exactly that shape: ## , a timestamp
with no whitespace in it, —, an author. Any other line beginning with ##
is prose and stays inside the entry it was written in, so quoting a markdown
heading in a comment does not inflate the comment count ls reports.
Unparsable chunks are kept as raw text, so a hand-edited or union-merged file
never makes show fail; they are not comments, and the count leaves them out.
Two things the writer does to keep that true:
- Body lines whose first non-blank character is
#are indented by one space. Markdown still renders them as headings, andshowstrips the space back off, but a comment can no longer contain something that reads as an entry header. - Before appending, it checks the file already ends with a blank line and
supplies whatever is missing. Every entry we write ends in
\n\n; a hand-edited tail may not, and a header glued onto the previous line would degrade that whole chunk to raw text.
.gitattributes marks **/d.md as merge=union, so concurrent comments merge
without a conflict. The pattern is **, not *, because a * does not cross a
/ and a closed task’s discussion is one level deeper.
A retitle or a close moves the folder, and a comment written on another clone
against the old name still lands in the new one: an existing d.md follows the
rename, and a first comment’s new d.md follows the directory rename, which
sync asks git to apply rather than stop on. Attachments need one more step; see
commands.md.
A comment or attachment added on one clone to a task the other clone removed
does not conflict either: git sees a delete and an unrelated add, and the
merge leaves a folder holding only the new files, with no t.md or m.yml.
The removal wins, the same rule m.yml follows (“removed on one side goes”),
and ids are never reused, so sync drops the folder with a note rather than
raising a conflict nobody could resolve into a loadable task — see
commands.md.
7. config.toml
Committed inside .yman/, so a project agrees on it once.
version = 1
[ids]
scheme = "seq" # "random" | "seq" | "author"
random_len = 4 # hex chars, random scheme only
[statuses]
list = ["todo", "doing", "done"]
default = "todo"
# version 2 only, all optional:
# start = "doing" # yman start; default list[1]
# done = "done" # yman done; default list.last()
# cancel = "cancelled" # yman cancel; no default
# terminal = ["done"] # closed statuses, hidden by ls
[priorities]
default = 5 # 0..=9
[slug]
max_bytes = 200 # hard cap 240
Validated on every load; each failure is reported as
invalid .yman/config.toml: <why>:
| Rule |
|---|
version is 1 or 2 |
statuses.list.len() >= 2 |
every statuses.list entry is non-blank, has no edge whitespace or control character, and appears once |
statuses.list contains statuses.default |
statuses.start, .done, .cancel, .terminal need version = 2 |
statuses.start, .done, .cancel are in statuses.list |
every statuses.terminal entry is in statuses.list |
every statuses.terminal entry is a usable directory name |
statuses.terminal has no repeat, and none differing only in ASCII case |
statuses.terminal contains the done status, and the cancel status when set |
statuses.done is named whenever statuses.terminal lists more than one status |
statuses.terminal excludes statuses.default and the start status (version = 2 only) |
priorities.default <= 9 |
slug.max_bytes in 1..=240 |
ids.random_len in 2..=16 |
[priorities] and [slug] may be omitted entirely.
Status roles
Three roles name the statuses the verbs move to, and one list says which statuses count as closed:
| Key | Meaning | When unset |
|---|---|---|
statuses.start | yman start | list[1] |
statuses.done | yman done | list.last() |
statuses.cancel | yman cancel | the command refuses |
statuses.terminal | closed: hidden by ls | [done status] |
Naming them is what stops a reordered list from silently changing what
yman start means. For the same reason statuses.done is required as soon as
statuses.terminal names more than one status: with one closed status the
done ∈ terminal rule pins it down, but with two, list.last() decides what
yman done means and can get it wrong without failing. Leaving them unset reproduces the positional behaviour
exactly, so an untouched file keeps working.
A terminal status doubles as a directory name (see §5), so it is restricted to
letters, digits, _ and -, at most 64 bytes, and must not start with -.
That grammar also keeps a status from colliding with config.toml, .git or a
task folder, since all of those contain a .. Two terminal statuses differing
only in ASCII case are rejected because they are one directory on macOS and
Windows.
Two rules — terminal excluding default and excluding the start status —
apply only at version = 2; excluding default already keeps one status
open. A version 1 list of ["todo", "done"] derives
start == done == the terminal status and works fine; enforcing the rules
against it would reject a config that ships today.
Opting in to version 2
yman init writes version = 1, so a new repository stays readable by older
binaries. Raising the version is a hand edit, and it is what turns archiving on:
version = 2
[statuses]
list = ["todo", "doing", "blocked", "done", "cancelled"]
default = "todo"
start = "doing"
done = "done"
cancel = "cancelled"
terminal = ["done", "cancelled"]
From that point an older yman refuses the repository outright with
unsupported version 2 (this yman understands 1) — which is the intended
failure, because it also would not find the archived tasks.
8. Id schemes
Chosen at init, stored in config.toml, identical for everyone on the
project.
| Scheme | Shape | Next id |
|---|---|---|
seq | 1, 2, 14 | highest all-digit id taken, plus one |
author | iv-1, an-3 | highest {prefix}-N taken, plus one |
random | t-7f3a | t- plus random_len hex chars, redrawn until free |
A scheme with no id left fails instead of looping or wrapping: random once
every random_len-character id is taken, seq and author past
18446744073709551615.
“Taken” means the union of the ids on disk and every id that ever had a file
added under it across LOCAL and REMOTE
(git log --no-renames --diff-filter=A --name-only -z). Deleting a task
therefore never frees its id — a later add cannot reuse it and confuse the
history. Rename detection is off because a sync renumber is a git mv, which
would otherwise read as a rename and hide the id it lands on; -z keeps paths
unquoted whatever characters they hold.
The author prefix resolves in order: git config yman.author, $YMAN_AUTHOR,
then the initials of user.name (first letter of each word, lowercased, ASCII
letters only). With none of those available, add fails rather than guess.
The prefix must match [A-Za-z0-9_-]+, and whichever source supplies it is
checked every time it is read. A setting that is empty or all whitespace counts
as unset and falls through to the next source; anything else that fails the
grammar fails add, naming the value and the source it came from. The reason
is §5: the prefix lands in the folder name, where a . is read as the id
separator. yman init --author stores the value without checking it, so a bad
prefix surfaces at the first add. Ids minted before the rule are left alone —
a collision renumber re-derives the prefix from the id it finds, so an old task
stays renumberable.