Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Docs: rewrite the README and organize the guides

520470e19cd906bd6fff987e2d132d793ce56486 · Keenan Nemetz · 16d ago · parent c5902451

Docs: rewrite the README and organize the guides

Replace the long README with a landing page, split feature details into focused guides, and remove the roadmap and spike notes. Update comments that referenced them.

docs/spikes/issue-frontmatter/README.md −200

@@ -1,200 +0,0 @@
1 # Spike: frontmatter as the issue format on `refs/heads/vole`
2
3 **Question.** Issues are `issues/<ulid>/meta.json` + `description.md` today. If the
4 metadata became YAML frontmatter on a single `issue.md`, would the diffs actually
5 get cleaner, and how much of YAML's syntax would the validator have to police?
6
7 **Short answer.** Yes, and almost none — if the writer always double-quotes free
8 text. Measured: 25 changed lines today versus 10 for the same six edits, with the
9 only new rule being "an unquoted title must parse back to its own text".
10
11 ## Method
12
13 `main.go` has two modes.
14
15 ```sh
16 go run . -mode probe # 36 adversarial titles, 15 labels
17 go run . -mode diffs # 7-step edit sequence, real git repos, both formats
18 go run . -mode diffs -no-updated # same, with the updated field dropped
19 ```
20
21 `-mode diffs` initialises a throwaway git repo per format, commits the same edit
22 sequence, and counts what `git diff --numstat` reports — not what the writer
23 believes it wrote. The JSON side reproduces the `meta` struct in
24 `metadata/issues.go` — field order, `omitempty` set, two-space
25 `MarshalIndent` — so the numbers describe the format that ships.
26
27 ## Results: changed lines per edit
28
29 One issue, six edits: add a label, close, reopen, retitle, edit the body, remove
30 the last label.
31
32 | Edit | `meta.json` + `description.md` | `issue.md` frontmatter |
33 |---|---|---|
34 | add label | +3 −1 = **4** | +2 −0 = **2** |
35 | close | 4 | 4 |
36 | reopen | 4 | 4 |
37 | retitle | 4 | 4 |
38 | edit body | 4 | 4 |
39 | remove last label | **5** | **3** |
40 | **total** | **25** | **21** |
41
42 The label rows are the difference the format was chosen for: JSON rewrites the
43 previous array element to add a comma, so one label costs two lines and removing
44 one costs three.
45
46 Totals for a labelless issue getting its first label and losing it again:
47
48 | | JSON | frontmatter |
49 |---|---|---|
50 | add first label | **6** | **3** |
51 | remove it again | 5 | 4 |
52 | total | **11** | **7** |
53
54 ### The `updated` field is most of the remaining diff
55
56 Dropping `updated` from both formats, same six edits:
57
58 | | JSON | frontmatter |
59 |---|---|---|
60 | one issue, six edits | **14** | **10** |
61 | first label ever | **10** | **4** |
62
63 So 11 of the 21 frontmatter lines are the timestamp, and 11 of JSON's 25. Neither
64 format needs it: the commit already carries the time, `git log -- issues/<id>`
65 answers "when did this change" per file, and the field is written by `Update`
66 whether or not anyone asked a question it answers.
67
68 Two smaller measured wins:
69
70 - A title containing markup survives readable. JSON writes
71 `"title": "crash on a\u003cb\u003e \u0026 c"`; frontmatter writes
72 `title: "crash on a<b> & c"`.
73 - An edit touches one file instead of two, because the body lives under the
74 metadata instead of beside it.
75
76 ## Results: which YAML cases need policing
77
78 36 titles through three writers: (a) `yaml.Marshal` of a struct, (b) a writer
79 that always double-quotes, (c) a human editing by hand and forgetting the quotes.
80
81 | | count |
82 |---|---|
83 | round-trip correctly through `yaml.Marshal` | 36/36 |
84 | folded onto more than one line by `yaml.Marshal` (200-char title included) | **0/36** |
85 | round-trip through an always-quoting writer | **36/36** |
86 | change value when hand-edited unquoted | 18/36 |
87 | …with a parse error (loud: `pre-receive` names the file) | 11 |
88 | …becoming empty, silently (`&anchor`, `!tag`, `null`, `~`) | 4 |
89 | …truncated at `#`, silently | 1 |
90 | …differing only by trimmed whitespace | 2 |
91 | rejected by the rule below | 16 |
92
93 The eleven loud ones are the YAML indicators — `: `, `- `, `*`, `%`, `@`, `` ` ``,
94 `{`, `[`, `>`, `|`, `?`. A hand-edit that drops the quotes on those gets a parse
95 error, which is the behaviour you want.
96
97 The silent ones are the hazard, and there are two shapes:
98
99 ```
100 title: fix #12 crash → "fix" a comment starts at " #"
101 title: &anchor → "" parsed as an anchor, not a string
102 title: null → ""
103 ```
104
105 Only the first is real data loss — the others are a scoped title that a YAML
106 reader cannot see as a string. Both are caught by the rule below. The two
107 whitespace cases are not corruption: `New` and the validator already trim titles.
108
109 **The rule that closes it:** a title line that is not quoted must parse back to
110 exactly its own text; otherwise reject it and say why. Quoted lines are taken at
111 face value. That catches all 16 ambiguous cases (including the `#` truncation)
112 without a single reserved-word list, and it cannot drift from the parser because
113 it asks the parser.
114
115 Labels behave differently, because a label is decoded into a `string` and yaml.v3
116 is happy to coerce:
117
118 | label | unquoted decodes as | written by `plainSafe` |
119 |---|---|---|
120 | `bug`, `needs-repro`, `a-b_c.d` | unchanged | unquoted |
121 | `yes`, `on`, `no`, `off`, `y`, `n` | unchanged (YAML 1.2, string target) | unquoted |
122 | `123`, `1.5`, `0x10` | unchanged | unquoted |
123 | `null` | parse error | `"null"` |
124 | `~` | parse error | `"~"` (already forbidden by `labelRE`) |
125
126 So quoting only when the plain form fails to round-trip keeps 13 of 15 labels
127 looking exactly as they do today, and the one label a forge is likely to actually
128 have (`null`) is quoted automatically.
129
130 ## Verdict: VALIDATED
131
132 The format is better on every measured axis, and the restrictions it needs are
133 narrower than expected — mostly because the writer, not the validator, does the
134 work by always quoting.
135
136 ### What worked
137
138 - Adding and removing a label costs one changed line instead of two to four.
139 - One file per issue means an edit touches one path, and `git log`/`blame` per
140 issue carry the whole history.
141 - `yaml.Marshal` never folded a 200-byte title, so `maxTitleLen = 200` survives.
142 - The reader can be `yaml.v3` with `KnownFields(true)`, which keeps the current
143 "unknown field" rejection without a hand-written key whitelist.
144
145 ### What didn't
146
147 - YAML still has a silent-truncation hole for hand-edits (`#`). It costs one
148 comparison to close, but it must be written down or the hole reopens.
149 - `null` as a label needs quoting. Cheap, but it is a difference between two
150 labels that look identical in the source.
151 - The `updated` field ruins the good numbers. It should go regardless of format.
152
153 ### Surprises
154
155 - The biggest win wasn't YAML at all: it was dropping `updated` (25 → 14) and
156 folding the body into the same file as the metadata.
157 - Unquoted `yes`/`on`/`no` are safe here — the YAML 1.1 boolean words only bite
158 when the target is untyped. The `null`/`~` failures come from `!!null`, not
159 `!!bool`.
160 - Library emission is the wrong tool: `yaml.Marshal` produces correct YAML, but
161 the always-quoting writer is smaller, deterministic, and can refuse control
162 characters instead of escaping them onto a second line.
163
164 ### Recommendation for the real build
165
166 Format, replacing `meta.json` + `description.md` with one file:
167
168 ```markdown
169 ---
170 title: "crash on a<b> & c"
171 state: open
172 author: keenan
173 created: 2026-10-23T12:00:00Z
174 labels:
175 - bug
176 - needs-repro
177 ---
178 It broke.
179
180 Repro: run vole.
181 ```
182
183 - Writer: double-quote `title` always; quote a label only when `plainSafe` fails.
184 Refuse control characters in titles rather than escaping them.
185 - Reader: `yaml.v3` decoder with `KnownFields(true)` over the fenced head.
186 - New validation rule: an unquoted `title` line must parse back to its own text.
187 - Drop `updated`. Commit time is the timestamp.
188 - Comments keep the format they already have; nothing about them changes.
189 - `requests/<ulid>/meta.json` has the same shape (`base`, `head`, `state`,
190 `author`, `created`) and should move in the same change, or the branch has two
191 dialects.
192
193 Touched by that change: `metadata/{issues,issue_write}.go`,
194 `metadata/requests.go`, the path whitelist and hook messages in
195 `server/hook/hook.go`, the format text in `server/web/templates.go`, the CLI
196 sugar in `cmd/`, and the tree fixtures in the tests.
197
198 Not settled here: existing issues on a live instance need either a one-shot
199 rewrite commit or a release of dual-format reading. The parse and render paths are
200 both confined to the two packages above, so either is small.