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. | ||