docs/plans/2026-09-19-profile-about-repo.md
1306 lines · 43814 bytes
1# Profile about in a repository — implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Move the user/org profile about text out of the `users`/`orgs`
6columns and into `profile/README.{md,org}` on the default branch of a
7repository named `.gitbay` under the owner's namespace.
8
9**Architecture:** The about becomes a file, read the way wiki pages are
10read — no store rows, access derived from the parent repository. The
11control command's JSON keeps its `about`/`about_format` fields, so the
12API and the iOS client do not move. Writes stop going through
13`profile set`; the file is written by a push or `repo commit-file`. A
14SQL migration parks the existing text in a holding table and drops the
15columns; a `gitbayd admin` one-shot drains the table into repositories.
16
17**Tech Stack:** Go, SQLite (modernc.org/sqlite), `git` subprocesses via
18`internal/gitutil`, `html/template`.
19
20**Spec:** `docs/specs/2026-09-19-profile-about-repo-design.md`
21
22## Global Constraints
23
24- Repository name `.gitbay`; file `profile/README` plus one of `.md`,
25 `.org`, `.markdown`, resolved in that order.
26- `ProfileOut.About` and `ProfileOut.AboutFormat` keep their JSON names.
27 `AboutFormat` is `org` for a `.org` file, `md` otherwise.
28- A repository the caller cannot read yields an empty about, never an
29 error — the private-repo rule is 404-shaped, and a profile must not
30 confirm a namespace.
31- Blob reads are capped at `maxCommitFileBytes` (1MB), already defined
32 in `internal/control/commitfile.go`.
33- Never attribute anything to an assistant or model, anywhere.
34- Commit messages reference the issue: `Ref #236`, and the last one
35 `Closes #236`.
36- Branch is `profile-about-236`; never push to `main`.
37
38---
39
40### Task 1: Repository names may start with a dot
41
42`.gitbay` is an invalid repository name today: `namePat` requires a
43leading alphanumeric. Relax it, and close the hole that lets a
44repository be named exactly `.git`.
45
46**Files:**
47- Modify: `internal/policy/names.go:37` (namePat), `:52-69` (ValidateName)
48- Test: `internal/policy/names_test.go`
49
50**Interfaces:**
51- Consumes: nothing.
52- Produces: `policy.ValidateName(name string) error` accepts a single
53 leading dot. Task 6 relies on `.gitbay` validating.
54
55- [ ] **Step 1: Write the failing test**
56
57Append to `internal/policy/names_test.go`:
58
59```go
60func TestValidateNameLeadingDot(t *testing.T) {
61 for _, name := range []string{".gitbay", ".dotfiles", ".a"} {
62 if err := ValidateName(name); err != nil {
63 t.Errorf("ValidateName(%q) = %v, want nil", name, err)
64 }
65 }
66 for _, name := range []string{".", "..", ".git", "repo.git", "..a", ".-a"} {
67 if err := ValidateName(name); err == nil {
68 t.Errorf("ValidateName(%q) = nil, want error", name)
69 }
70 }
71 // The ceiling is 63 characters, dot included.
72 if err := ValidateName("." + strings.Repeat("a", 62)); err != nil {
73 t.Errorf("63-character dotted name rejected: %v", err)
74 }
75 if err := ValidateName("." + strings.Repeat("a", 63)); err == nil {
76 t.Error("64-character dotted name accepted")
77 }
78}
79```
80
81Add `"strings"` to that file's imports if it is not already there.
82
83- [ ] **Step 2: Run it and watch it fail**
84
85Run: `go test ./internal/policy/ -run TestValidateNameLeadingDot -v`
86Expected: FAIL — `ValidateName(".gitbay")` returns an invalid-name error.
87
88- [ ] **Step 3: Relax the pattern**
89
90In `internal/policy/names.go`, replace the `namePat` declaration and its
91comment:
92
93```go
94// namePat matches valid user, org, and repo names: lowercase alphanumerics,
95// dot, dash, underscore; must start with an alphanumeric, or with a single
96// dot before one. A leading dot marks a repository as infrastructure rather
97// than a project — .gitbay holds an owner's profile content. Dots are
98// further restricted by ValidateName to avoid "." / ".." and ".git".
99var namePat = regexp.MustCompile(`^\.?[a-z0-9][a-z0-9._-]{0,61}$`)
100```
101
102- [ ] **Step 4: Refuse `.git` exactly, not just as a suffix**
103
104In `ValidateName`, replace the suffix check:
105
106```go
107 if len(name) > 4 && name[len(name)-4:] == ".git" {
108 return fmt.Errorf("invalid name %q: must not end in .git", name)
109 }
110```
111
112with:
113
114```go
115 // A name of exactly ".git" is now reachable through the leading-dot
116 // rule, and a bare .git directory in the namespace is not a thing to
117 // allow; HasSuffix covers both it and "repo.git".
118 if strings.HasSuffix(name, ".git") {
119 return fmt.Errorf("invalid name %q: must not end in .git", name)
120 }
121```
122
123`strings` is already imported there.
124
125- [ ] **Step 5: Run the package's tests**
126
127Run: `go test ./internal/policy/`
128Expected: PASS, including the pre-existing `TestValidateName`.
129
130- [ ] **Step 6: Commit**
131
132```bash
133git add internal/policy/names.go internal/policy/names_test.go
134git commit -m "policy: a repository name may start with a dot
135
136Ref #236"
137```
138
139---
140
141### Task 2: A first commit into an empty repository
142
143`CommitFileChange` resolves the branch and fails when it does not exist,
144so committing the first file into a freshly created `.gitbay` is
145impossible. Task 4's web button and Task 6's backfill both need it.
146
147Allow a root commit **only when the repository has no refs at all**, so
148that a typo'd branch name in a repository with history still fails the
149way it does today rather than silently starting an orphan branch.
150
151**Files:**
152- Modify: `internal/gitutil/merge.go:188-239` (CommitFileChange)
153- Test: `internal/gitutil/merge_test.go` (create if absent)
154
155**Interfaces:**
156- Consumes: `gitutil.ResolveRef(dir, ref) (string, error)`,
157 `gitutil.CommitTree(dir, tree string, parents []string, name, email, message string) (string, error)`,
158 `gitutil.UpdateRefCAS(dir, ref, newSHA, oldSHA string) error`.
159- Produces: `gitutil.CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error)`
160 — unchanged signature, now succeeding on an empty repository.
161
162- [ ] **Step 1: Write the failing test**
163
164Create or append to `internal/gitutil/merge_test.go`:
165
166```go
167func TestCommitFileChangeEmptyRepo(t *testing.T) {
168 dir := t.TempDir()
169 if err := InitBare(dir, "main", ""); err != nil {
170 t.Fatal(err)
171 }
172 sha, err := CommitFileChange(dir, "main", "profile/README.md",
173 []byte("# hello\n"), "alice", "alice@example.org", "add about")
174 if err != nil {
175 t.Fatalf("first commit into an empty repository: %v", err)
176 }
177 if sha == "" {
178 t.Fatal("no sha returned")
179 }
180 raw, err := ReadBlob(dir, "main", "profile/README.md", 1<<20)
181 if err != nil {
182 t.Fatalf("reading it back: %v", err)
183 }
184 if string(raw) != "# hello\n" {
185 t.Errorf("read back %q", raw)
186 }
187 // A second commit still takes the normal parented path.
188 if _, err := CommitFileChange(dir, "main", "profile/README.md",
189 []byte("# hello again\n"), "alice", "alice@example.org", "edit"); err != nil {
190 t.Fatalf("second commit: %v", err)
191 }
192 // A branch that does not exist in a repository that has history is
193 // still an error, not a new orphan branch.
194 if _, err := CommitFileChange(dir, "nope", "x.md",
195 []byte("x"), "alice", "alice@example.org", "x"); err == nil {
196 t.Error("committing to an unknown branch of a non-empty repository succeeded")
197 }
198}
199```
200
201Check `InitBare`'s signature in `internal/gitutil` before running; if
202its third parameter is not an optional hooks directory, pass what the
203existing callers in `internal/control/repo.go:217` pass.
204
205- [ ] **Step 2: Run it and watch it fail**
206
207Run: `go test ./internal/gitutil/ -run TestCommitFileChangeEmptyRepo -v`
208Expected: FAIL — `branch main: unknown ref "refs/heads/main"`.
209
210- [ ] **Step 3: Add the unborn-branch path**
211
212In `internal/gitutil/merge.go`, replace the opening of
213`CommitFileChange`:
214
215```go
216 branchRef := "refs/heads/" + branch
217 parent, err := ResolveRef(dir, branchRef)
218 if err != nil {
219 return "", fmt.Errorf("branch %s: %w", branch, err)
220 }
221```
222
223with:
224
225```go
226 branchRef := "refs/heads/" + branch
227 parent, err := ResolveRef(dir, branchRef)
228 if err != nil {
229 // An unborn branch is only a root commit in a repository with no
230 // refs at all. Anywhere else an unresolvable branch is a typo, and
231 // starting an orphan branch for it would be worse than refusing.
232 if !isEmptyRepo(dir) {
233 return "", fmt.Errorf("branch %s: %w", branch, err)
234 }
235 parent = ""
236 }
237```
238
239Replace the `read-tree` block:
240
241```go
242 rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}")
243 rt.Env = env
244 if out, err := rt.CombinedOutput(); err != nil {
245 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
246 }
247```
248
249with:
250
251```go
252 arg := parent + "^{tree}"
253 if parent == "" {
254 arg = "--empty"
255 }
256 rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", arg)
257 rt.Env = env
258 if out, err := rt.CombinedOutput(); err != nil {
259 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
260 }
261```
262
263Replace the `CommitTree` call:
264
265```go
266 sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
267```
268
269with:
270
271```go
272 var parents []string
273 if parent != "" {
274 parents = []string{parent}
275 }
276 sha, err := CommitTree(dir, tree, parents, name, email, message)
277```
278
279`UpdateRefCAS` already omits the old value when `parent` is empty, so
280the tail of the function is unchanged.
281
282- [ ] **Step 4: Add the emptiness check**
283
284Add below `CommitFileChange` in the same file:
285
286```go
287// isEmptyRepo reports whether dir has no refs at all — a repository
288// created but never pushed to.
289func isEmptyRepo(dir string) bool {
290 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "-n1", "--all").Output()
291 return err == nil && strings.TrimSpace(string(out)) == ""
292}
293```
294
295- [ ] **Step 5: Run the tests**
296
297Run: `go test ./internal/gitutil/`
298Expected: PASS.
299
300- [ ] **Step 6: Commit**
301
302```bash
303git add internal/gitutil/merge.go internal/gitutil/merge_test.go
304git commit -m "gitutil: commit-file writes the first commit of an empty repository
305
306Ref #236"
307```
308
309---
310
311### Task 3: Read the about from the repository
312
313**Files:**
314- Modify: `internal/control/profile.go` (constants, `ownerAbout`, `ProfileOut`, `runProfileShow`)
315- Modify: `internal/httpd/web.go` (`aboutHTML`, the profile handler at ~446)
316- Test: `e2e/profileabout_test.go` (create)
317
318**Interfaces:**
319- Consumes: `control.RepoDir(root, owner, name) string`,
320 `gitutil.ReadBlob(dir, ref, path string, limit int64) ([]byte, error)`,
321 `policy.CanRead(u store.User, r store.Repo, grant string) bool`,
322 `c.Store.RepoByPath(path) (store.Repo, error)`,
323 `c.Store.AccessRole(repoID, userID int64) (string, error)`,
324 `maxCommitFileBytes` from `internal/control/commitfile.go`.
325- Produces:
326 - `const ProfileRepoName = ".gitbay"` and `const AboutBase = "profile/README"` in `internal/control/profile.go` — Task 4, 5 and 6 use them.
327 - `func ownerAbout(c *Ctx, owner string) (text, format, path string)`.
328 - `ProfileOut.AboutPath string \`json:"about_path,omitempty"\`` — Task 4's template links to it.
329 - `func aboutHTML(text, format string) template.HTML` in `internal/httpd/web.go`.
330
331- [ ] **Step 1: Write the failing e2e test**
332
333Create `e2e/profileabout_test.go`:
334
335```go
336package e2e
337
338import (
339 "strings"
340 "testing"
341)
342
343// The about text is a file in <owner>/.gitbay, read on every surface
344// with the reader's own access.
345func TestProfileAboutFromRepo(t *testing.T) {
346 inst := startInstance(t)
347 aliceKey := inst.newKey(t, "alice")
348 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
349 bobKey := inst.newKey(t, "bob")
350 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
351
352 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay"); code != 0 {
353 t.Fatal("creating alice/.gitbay failed")
354 }
355 if _, _, code := inst.ssh(t, aliceKey, "# alice\n\nhello from a file\n",
356 "repo", "commit-file", "alice/.gitbay", "profile/README.md",
357 "--ref", "main", "--file", "-"); code != 0 {
358 t.Fatal("committing the about failed")
359 }
360
361 out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
362 if code != 0 {
363 t.Fatalf("profile show: %d", code)
364 }
365 if !strings.Contains(out, "hello from a file") {
366 t.Errorf("about not read from the repository: %s", out)
367 }
368 if !strings.Contains(out, `"about_format":"md"`) {
369 t.Errorf("about_format not md: %s", out)
370 }
371 if !strings.Contains(out, `"about_path":"profile/README.md"`) {
372 t.Errorf("about_path missing: %s", out)
373 }
374
375 _, body := inst.get(t, "/alice")
376 if !strings.Contains(body, "hello from a file") {
377 t.Error("web profile does not render the about")
378 }
379}
380
381// .org wins nothing over .md, and a private .gitbay keeps the about to
382// the people who can read it.
383func TestProfileAboutFormatAndPrivacy(t *testing.T) {
384 inst := startInstance(t)
385 aliceKey := inst.newKey(t, "alice")
386 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
387 bobKey := inst.newKey(t, "bob")
388 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
389
390 inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay", "--private")
391 inst.ssh(t, aliceKey, "* heading\n\norg text here\n",
392 "repo", "commit-file", "alice/.gitbay", "profile/README.org",
393 "--ref", "main", "--file", "-")
394
395 out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
396 if !strings.Contains(out, "org text here") || !strings.Contains(out, `"about_format":"org"`) {
397 t.Errorf("owner cannot read their own private about: %s", out)
398 }
399 out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
400 if code != 0 {
401 t.Fatalf("profile show for an outsider should succeed: %d", code)
402 }
403 if strings.Contains(out, "org text here") {
404 t.Errorf("private about leaked to an outsider: %s", out)
405 }
406
407 // A .md beside the .org wins: it is first in the resolution order.
408 inst.ssh(t, aliceKey, "markdown wins\n",
409 "repo", "commit-file", "alice/.gitbay", "profile/README.md",
410 "--ref", "main", "--file", "-")
411 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
412 if !strings.Contains(out, "markdown wins") {
413 t.Errorf(".md did not win resolution: %s", out)
414 }
415}
416```
417
418- [ ] **Step 2: Run them and watch them fail**
419
420Run: `go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m`
421Expected: FAIL — `repo create alice/.gitbay` succeeds after Task 1, but
422`profile show` reports no about, and `about_path` is absent.
423
424- [ ] **Step 3: Add the resolution helper**
425
426In `internal/control/profile.go`, after the `maxProfileLinks` constant:
427
428```go
429// ProfileRepoName is the repository that holds an owner's profile
430// content. A dot-repo because it is infrastructure rather than a
431// project: later per-owner configuration goes beside the about text,
432// and the leading dot keeps it out of listings.
433const ProfileRepoName = ".gitbay"
434
435// AboutBase is the about file's path in that repository, without its
436// extension.
437const AboutBase = "profile/README"
438
439// aboutExts are the formats the about is read from, in resolution
440// order — the wiki's order, for the same reason.
441var aboutExts = []string{".md", ".org", ".markdown"}
442
443// ownerAbout reads an owner's about text from <owner>/.gitbay. Anything
444// missing — the repository, the branch, the file — is an empty about,
445// and so is a repository this caller cannot read: a profile must not
446// confirm a private namespace. path is the file it came from, so a
447// client can link to it.
448func ownerAbout(c *Ctx, owner string) (text, format, path string) {
449 repo, err := c.Store.RepoByPath(owner + "/" + ProfileRepoName)
450 if err != nil {
451 return "", "", ""
452 }
453 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
454 if err != nil || !policy.CanRead(c.User, repo, grant) {
455 return "", "", ""
456 }
457 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
458 for _, ext := range aboutExts {
459 raw, err := gitutil.ReadBlob(dir, repo.DefaultBranch, AboutBase+ext, maxCommitFileBytes)
460 if err != nil || len(raw) == 0 {
461 continue
462 }
463 f := "md"
464 if ext == ".org" {
465 f = "org"
466 }
467 return string(raw), f, AboutBase + ext
468 }
469 return "", "", ""
470}
471```
472
473- [ ] **Step 4: Add `AboutPath` and read through the helper**
474
475In `ProfileOut`, below `AboutFormat`:
476
477```go
478 // AboutPath is where the about was read from in <owner>/.gitbay, so a
479 // client can link to the file rather than guess its extension.
480 AboutPath string `json:"about_path,omitempty"`
481```
482
483In `runProfileShow`, replace the `d := ProfileOut{...}` literal:
484
485```go
486 about, aboutFormat, aboutPath := ownerAbout(c, name)
487 d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
488 About: about, AboutFormat: aboutFormat, AboutPath: aboutPath,
489 Links: p.Links, Repos: []ProfileRepo{}}
490```
491
492- [ ] **Step 5: Render from the text, not from a store struct**
493
494In `internal/httpd/web.go`, replace `aboutHTML`:
495
496```go
497// aboutHTML renders a profile's about text. The format comes from the
498// file it was read from: org is org, anything else markdown.
499func aboutHTML(text, format string) template.HTML {
500 if strings.TrimSpace(text) == "" {
501 return ""
502 }
503 name := "about.md"
504 if format == "org" {
505 name = "about.org"
506 }
507 return renderReadme(name, []byte(text))
508}
509```
510
511In the profile handler near line 446, drop the about from the
512`store.Profile` literal:
513
514```go
515 profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links}
516```
517
518and update the `AboutHTML` field's value in the `s.render` struct
519literal to `aboutHTML(d.About, d.AboutFormat)`. Find its current call
520with `grep -n 'aboutHTML' internal/httpd/web.go` and change that
521argument list.
522
523- [ ] **Step 6: Build, vet, and run the tests**
524
525Run: `go build ./... && go vet ./... && go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m`
526Expected: PASS. `go vet` matters here — `aboutHTML`'s signature changed
527and `go build` does not compile `_test.go` callers.
528
529- [ ] **Step 7: Commit**
530
531```bash
532git add internal/control/profile.go internal/httpd/web.go e2e/profileabout_test.go
533git commit -m "profile: read the about text from <owner>/.gitbay
534
535Ref #236"
536```
537
538---
539
540### Task 4: Stop writing the about through `profile set`
541
542There is no about-specific write command, for the reason the wiki has
543none: the content is a file, written the way files are written.
544
545**Files:**
546- Modify: `internal/control/profile.go` (`register` usages, `profileEdit`, `parseProfileFlags`, `applyProfile`, `runProfileSet`, `runOrgProfile`)
547- Modify: `internal/httpd/account.go:42-87` (page struct), `:251-266` (the `profile` form case)
548- Modify: `internal/web/templates/account.html:25,34-36`
549- Modify: `internal/httpd/routes.go` if a new form case needs no route (it does not — `/settings` already takes the POST)
550- Test: `e2e/profileabout_test.go` (append)
551
552**Interfaces:**
553- Consumes: `control.ProfileRepoName`, `control.AboutBase` from Task 3;
554 `ProfileOut.AboutPath` from Task 3;
555 `s.runControl(u store.User, argv []string) (int, string, bool)` and
556 `s.runControlStdin(u store.User, argv []string, stdin string) (string, bool)`
557 in `internal/httpd` — confirm their exact signatures with
558 `grep -n 'func (s \*Server) runControl' internal/httpd/*.go` before use.
559- Produces: `profile set` and `org profile` with no `--about`,
560 `--about-format` or `--file`, and `ReadsStdin` unset.
561
562- [ ] **Step 1: Write the failing test**
563
564Append to `e2e/profileabout_test.go`:
565
566```go
567// The about is not settable through profile set any more: it is a file.
568func TestProfileSetHasNoAbout(t *testing.T) {
569 inst := startInstance(t)
570 aliceKey := inst.newKey(t, "alice")
571 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
572
573 _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "'inline text'")
574 if code == 0 {
575 t.Error("profile set --about still accepted")
576 }
577 // The flags that stay still work.
578 if _, _, code := inst.ssh(t, aliceKey, "",
579 "profile", "set", "--description", "'a line'", "--link", "'site|https://example.org'"); code != 0 {
580 t.Fatalf("profile set --description --link: %d", code)
581 }
582 out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
583 if !strings.Contains(out, "a line") || !strings.Contains(out, "https://example.org") {
584 t.Errorf("description or link not saved: %s", out)
585 }
586}
587```
588
589- [ ] **Step 2: Run it and watch it fail**
590
591Run: `go test ./e2e/ -run TestProfileSetHasNoAbout -v -timeout 10m`
592Expected: FAIL — `profile set --about` exits 0.
593
594- [ ] **Step 3: Drop the flags from the command registrations**
595
596In `internal/control/profile.go`'s `init`, replace the two
597registrations:
598
599```go
600 register(Command{Path: []string{"profile", "set"},
601 Summary: "set your profile",
602 Usage: "profile set [--description <d>] [--website <url>] [--link <label|url>]... ('' clears)", Run: runProfileSet})
603 register(Command{Path: []string{"org", "profile"},
604 Summary: "show or set an org's profile",
605 Usage: "org profile <org> [--description <d>] [--website <url>] [--link <label|url>]...", Run: runOrgProfile})
606```
607
608`ReadsStdin` is gone from both; `TestStdinCommandsReadStdin` enforces
609that a command that no longer reads stdin does not claim to.
610
611- [ ] **Step 4: Drop the fields from the edit struct and the parser**
612
613In `profileEdit`, delete the `About` and `AboutFormat` fields. Update
614`empty()`:
615
616```go
617func (e profileEdit) empty() bool {
618 return e.Description == nil && e.Website == nil && e.Links == nil
619}
620```
621
622In `parseProfileFlags`, delete the `about`, `file` and `sawAbout`
623locals, the `--about` / `--about-format` / `--file` entries from
624`flagSpec.Values`, the `--about-format` case from the loop over the
625value flags, the two `if f.Has(...)` blocks that set them, and the
626`if sawAbout { ... bodyFrom ... }` block. The signature keeps its
627`*Ctx` parameter — `parseFlags` errors still flow through `c` in the
628callers — but if the compiler reports `c` unused, rename it to `_` in
629the parameter list and update both call sites.
630
631In `applyProfile`, delete the `e.About` and `e.AboutFormat` blocks.
632
633In `runProfileSet`, change the "nothing to set" message:
634
635```go
636 return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website and/or --link")
637```
638
639In both `runProfileSet` and `runOrgProfile`, drop `About:` and
640`AboutFormat:` from the `ProfileOut` literals they emit.
641
642- [ ] **Step 5: Build and fix what falls out**
643
644Run: `go build ./... && go vet ./...`
645Expected: errors in `internal/httpd/account.go` and possibly
646`internal/control/migrate.go`. `internal/control/migrate.go` uses
647`store.Profile` as a whole and needs no change until Task 6. Fix only
648`account.go` here, per Step 6.
649
650- [ ] **Step 6: Point the account page at the file**
651
652In `internal/httpd/account.go`, in the `profile` case of the settings
653form handler, replace the whole case body:
654
655```go
656 case "profile":
657 argv := []string{"profile", "set",
658 "--description", r.FormValue("description"),
659 "--website", r.FormValue("website"),
660 }
661 for _, link := range profileLinkArgs(r.FormValue("links")) {
662 argv = append(argv, "--link", link)
663 }
664 if _, msg, ok := s.runControl(u, argv); !ok {
665 back(msg, "")
666 return
667 }
668 back("", "profile updated")
669 case "profile-repo":
670 // The about text is a file. Create the repository that holds it and
671 // commit a starter README, so the file editor has a branch to open.
672 path := u.Username + "/" + control.ProfileRepoName
673 if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok {
674 back(msg, "")
675 return
676 }
677 starter := "# " + u.Username + "\n\nThis is your profile's about text.\n"
678 if msg, ok := s.runControlStdin(u,
679 []string{"repo", "commit-file", path, control.AboutBase + ".md",
680 "--ref", "main", "--message", "add profile about", "--file", "-"}, starter); !ok {
681 back(msg, "")
682 return
683 }
684 back("", "profile repository created")
685```
686
687`runControl`'s return shape is `(code int, msg string, ok bool)` in the
688`theme` case above — match it exactly. In `accountPage`, add two fields to the anonymous page struct after
689`LinksText`:
690
691```go
692 AboutRepo string // "<user>/.gitbay", the repository that holds the about
693 AboutEdit string // the file editor's URL, empty when the repository has no about yet
694```
695
696and compute them before `s.render`:
697
698```go
699 aboutRepo := u.Username + "/" + control.ProfileRepoName
700 aboutEdit := ""
701 if profile.AboutPath != "" {
702 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
703 }
704```
705
706then add `aboutRepo, aboutEdit` to the struct literal's value list in
707the same position as the fields.
708
709- [ ] **Step 7: Replace the textarea with the pointer**
710
711In `internal/web/templates/account.html`, delete line 25
712(`{{if .Draft.Is "about"}}...{{end}}`), the About `<label>` and
713`<textarea>`, and the `formatpicker` line. Replace the button group's
714`{{template "previewbtn"}}` with nothing, leaving:
715
716```html
717 <span class="btngroup"><button type="submit" class="btn">Save profile</button></span>
718</form>
719<p class="meta">Your about text is a file: <code>{{.AboutRepo}}</code> ·
720<code>profile/README.md</code>. {{if .AboutEdit}}<a href="{{.AboutEdit}}">Edit it</a>.{{else}}
721It has no repository yet.{{end}}</p>
722{{if not .AboutEdit}}<form method="post" action="/settings" class="setform">
723 <input type="hidden" name="field" value="profile-repo">
724 <button type="submit" class="btn">Create {{.AboutRepo}}</button>
725</form>{{end}}
726```
727
728- [ ] **Step 8: Run the tests**
729
730Run: `go build ./... && go vet ./... && go test ./internal/httpd/ ./internal/control/ && go test ./e2e/ -run 'TestProfile' -v -timeout 10m`
731Expected: PASS. If `TestMainWidthClass` fails, a template was added —
732it was not, so investigate rather than paper over it.
733
734- [ ] **Step 9: Commit**
735
736```bash
737git add internal/control/profile.go internal/httpd/account.go internal/web/templates/account.html e2e/profileabout_test.go
738git commit -m "profile: the about text is written as a file, not a flag
739
740Ref #236"
741```
742
743---
744
745### Task 5: Hide dot-repos from explore and profile listings
746
747Hiding the repository is what a dot-repo buys over `cmc/cmc`; without
748this the move trades one visible single-purpose repository for another.
749
750**Files:**
751- Modify: `internal/control/explore.go:54-` (the listing loop)
752- Modify: `internal/control/profile.go` (`runProfileShow`'s repo loop)
753- Test: `e2e/profileabout_test.go` (append)
754
755**Interfaces:**
756- Consumes: `store.Repo.Name`.
757- Produces: nothing new.
758
759- [ ] **Step 1: Write the failing test**
760
761Append to `e2e/profileabout_test.go`:
762
763```go
764// A dot-repo is infrastructure: it stays out of explore and off the
765// profile's repository list, and stays in the owner's own inventory.
766func TestDotReposHiddenFromListings(t *testing.T) {
767 inst := startInstance(t)
768 aliceKey := inst.newKey(t, "alice")
769 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
770 inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay")
771 inst.ssh(t, aliceKey, "", "repo", "create", "alice/app")
772
773 out, _, _ := inst.ssh(t, aliceKey, "", "explore", "--json")
774 if strings.Contains(out, ".gitbay") {
775 t.Errorf("dot-repo listed in explore: %s", out)
776 }
777 if !strings.Contains(out, "alice/app") {
778 t.Errorf("ordinary repo missing from explore: %s", out)
779 }
780
781 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
782 if strings.Contains(out, `"path":"alice/.gitbay"`) {
783 t.Errorf("dot-repo listed on the profile: %s", out)
784 }
785
786 out, _, _ = inst.ssh(t, aliceKey, "", "repo", "list", "--json")
787 if !strings.Contains(out, "alice/.gitbay") {
788 t.Errorf("dot-repo missing from the owner's own inventory: %s", out)
789 }
790
791 // It is still reachable at its URL.
792 if status, _ := inst.get(t, "/alice/.gitbay"); status != 200 {
793 t.Errorf("dot-repo page returned %d", status)
794 }
795}
796```
797
798Check `inst.get`'s return shape against `e2e/commentmigrate_test.go`
799(`_, body := inst.get(...)`) and adjust the status assertion to match.
800
801- [ ] **Step 2: Run it and watch it fail**
802
803Run: `go test ./e2e/ -run TestDotReposHiddenFromListings -v -timeout 10m`
804Expected: FAIL — `.gitbay` appears in explore and on the profile.
805
806- [ ] **Step 3: Filter the two listings**
807
808In `internal/control/explore.go`, inside the `for _, repo := range repos`
809loop, above the cursor check:
810
811```go
812 // A dot-repo is infrastructure, not a project; .gitbay holds an
813 // owner's profile content and has nothing to explore.
814 if strings.HasPrefix(repo.Name, ".") {
815 continue
816 }
817```
818
819Add `"strings"` to that file's imports if absent.
820
821In `internal/control/profile.go`, inside `runProfileShow`'s
822`for _, repo := range all` loop, above the access check:
823
824```go
825 if strings.HasPrefix(repo.Name, ".") {
826 continue
827 }
828```
829
830- [ ] **Step 4: Run the tests**
831
832Run: `go build ./... && go test ./e2e/ -run 'TestProfile|TestDotRepos' -v -timeout 10m`
833Expected: PASS.
834
835- [ ] **Step 5: Commit**
836
837```bash
838git add internal/control/explore.go internal/control/profile.go e2e/profileabout_test.go
839git commit -m "explore, profile: dot-repos stay out of the listings
840
841Ref #236"
842```
843
844---
845
846### Task 6: Migration and backfill
847
848A SQL migration cannot write git objects, and `gitbayd` runs
849`MigrateUp` at startup — so a backfill that reads the columns must not
850run after the migration that drops them. The migration parks the text
851in a holding table; a one-shot drains it.
852
853**Files:**
854- Create: `internal/store/migrations/0058_profile_about_out.up.sql`
855- Create: `internal/store/migrations/0058_profile_about_out.down.sql`
856- Create: `internal/store/aboutbackfill.go`
857- Create: `cmd/gitbayd/adminabout.go`
858- Modify: `internal/store/orgs.go:226-280` (`Profile`, `OwnerProfile`, `SetOwnerProfile`)
859- Modify: `cmd/gitbayd/main.go:390-402` (register the command)
860- Test: `e2e/aboutbackfill_test.go` (create)
861
862**Interfaces:**
863- Consumes: `control.ProfileRepoName`, `control.AboutBase` (Task 4),
864 `control.RepoDir(root, owner, name) string`,
865 `gitutil.InitBare(dir, branch, hooksDir string) error`,
866 `gitutil.CommitFileChange(...)` (Task 2),
867 `store.CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error)`.
868- Produces:
869 - `func (s *Store) PendingAboutBackfill() ([]AboutRow, error)` and
870 `func (s *Store) ClearAboutBackfill(kind string, id int64) error`,
871 with `type AboutRow struct { OwnerKind string; OwnerID int64; OwnerName string; About string; Format string }`.
872 - `gitbayd admin migrate-profile-about`.
873
874- [ ] **Step 1: Write the migration**
875
876`internal/store/migrations/0058_profile_about_out.up.sql`:
877
878```sql
879-- The about text moves into profile/README.* in <owner>/.gitbay. A SQL
880-- migration cannot write git objects, so the text is parked here and
881-- `gitbayd admin migrate-profile-about` drains the table into
882-- repositories. A later release drops the emptied table.
883CREATE TABLE profile_about_backfill (
884 owner_kind TEXT NOT NULL,
885 owner_id INTEGER NOT NULL,
886 about TEXT NOT NULL,
887 about_format TEXT NOT NULL,
888 PRIMARY KEY (owner_kind, owner_id)
889);
890
891INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
892SELECT 'user', id, about, about_format FROM users WHERE about <> '';
893
894INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
895SELECT 'org', id, about, about_format FROM orgs WHERE about <> '';
896
897ALTER TABLE users DROP COLUMN about;
898ALTER TABLE users DROP COLUMN about_format;
899ALTER TABLE orgs DROP COLUMN about;
900ALTER TABLE orgs DROP COLUMN about_format;
901```
902
903`internal/store/migrations/0058_profile_about_out.down.sql`:
904
905```sql
906ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
907ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
908ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT '';
909ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
910
911UPDATE users SET about = (SELECT about FROM profile_about_backfill
912 WHERE owner_kind = 'user' AND owner_id = users.id),
913 about_format = (SELECT about_format FROM profile_about_backfill
914 WHERE owner_kind = 'user' AND owner_id = users.id)
915 WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'user');
916
917UPDATE orgs SET about = (SELECT about FROM profile_about_backfill
918 WHERE owner_kind = 'org' AND owner_id = orgs.id),
919 about_format = (SELECT about_format FROM profile_about_backfill
920 WHERE owner_kind = 'org' AND owner_id = orgs.id)
921 WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'org');
922
923DROP TABLE profile_about_backfill;
924```
925
926- [ ] **Step 2: Run the migration round-trip test**
927
928Run: `go test ./internal/store/ -run TestMigrateUpDown -v`
929Expected: FAIL to compile — `OwnerProfile` still selects the dropped
930columns. Proceed to Step 3, then re-run.
931
932- [ ] **Step 3: Take the about out of the store's profile**
933
934In `internal/store/orgs.go`, delete the `About` and `AboutFormat` fields
935from `Profile`, and update its doc comment:
936
937```go
938// Profile is the presentational half of a user or org. The about text
939// is not here: it is a file in <owner>/.gitbay, read through the
940// control layer.
941type Profile struct {
942 Description string `json:"description,omitempty"`
943 Website string `json:"website,omitempty"`
944 Links []ProfileLink `json:"links,omitempty"`
945}
946```
947
948In `OwnerProfile`, drop the two columns from the SELECT and the two
949scan targets:
950
951```go
952 err := s.DB.QueryRow(
953 "SELECT description, website, links FROM "+table+" WHERE id = ?", id).
954 Scan(&p.Description, &p.Website, &linksJSON)
955```
956
957In `SetOwnerProfile`, drop the `AboutFormat` defaulting block and the
958two columns from the UPDATE:
959
960```go
961 _, err := s.DB.Exec(
962 "UPDATE "+table+" SET description = ?, website = ?, links = ? WHERE id = ?",
963 p.Description, p.Website, links, id)
964```
965
966- [ ] **Step 4: Run build, vet and the store tests**
967
968Run: `go build ./... && go vet ./... && go test ./internal/store/`
969Expected: PASS. `internal/control/migrate.go` embeds `store.Profile` in
970its account bundle; the fields simply disappear from that JSON, and an
971older bundle carrying them still imports because `encoding/json` ignores
972unknown fields. No bundle version bump.
973
974- [ ] **Step 5: Write the failing backfill test**
975
976Create `e2e/aboutbackfill_test.go`:
977
978```go
979package e2e
980
981import (
982 "path/filepath"
983 "strings"
984 "testing"
985
986 "gitbay.org/gitbay/internal/store"
987)
988
989func TestMigrateProfileAbout(t *testing.T) {
990 inst := startInstance(t)
991 aliceKey := inst.newKey(t, "alice")
992 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
993
994 // Seed the holding table the way migration 0058 would have.
995 dbPath := filepath.Join(inst.root, "gitbay.db")
996 st, err := store.Open(dbPath)
997 if err != nil {
998 t.Fatal(err)
999 }
1000 _, err = st.DB.Exec(
1001 "INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) "+
1002 "VALUES ('user', (SELECT id FROM users WHERE username='alice'), ?, 'org')",
1003 "* alice\n\ntext from the database\n")
1004 st.Close()
1005 if err != nil {
1006 t.Fatal(err)
1007 }
1008
1009 inst.admin(t, "admin", "migrate-profile-about")
1010
1011 out, _, code := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
1012 if code != 0 {
1013 t.Fatalf("profile show: %d", code)
1014 }
1015 if !strings.Contains(out, "text from the database") {
1016 t.Errorf("about not moved into the repository: %s", out)
1017 }
1018 if !strings.Contains(out, `"about_path":"profile/README.org"`) {
1019 t.Errorf("about not written at the recorded format: %s", out)
1020 }
1021
1022 // Idempotent: a second run is a no-op and leaves the table empty.
1023 inst.admin(t, "admin", "migrate-profile-about")
1024 st, _ = store.Open(dbPath)
1025 var n int
1026 st.DB.QueryRow("SELECT count(*) FROM profile_about_backfill").Scan(&n)
1027 st.Close()
1028 if n != 0 {
1029 t.Errorf("holding table still has %d row(s)", n)
1030 }
1031}
1032```
1033
1034`inst.admin`'s first argument is the admin subcommand path as used in
1035`e2e/commentmigrate_test.go` — confirm the exact call shape there
1036(`inst.admin(t, "admin", "user", "create", ...)`) and match it.
1037
1038- [ ] **Step 6: Run it and watch it fail**
1039
1040Run: `go test ./e2e/ -run TestMigrateProfileAbout -v -timeout 10m`
1041Expected: FAIL — no such subcommand.
1042
1043- [ ] **Step 7: Read and clear the holding table**
1044
1045Create `internal/store/aboutbackfill.go`:
1046
1047```go
1048package store
1049
1050// AboutRow is one owner's parked about text, waiting to become a file
1051// in <owner>/.gitbay. Migration 0058 fills the table; the backfill
1052// command drains it.
1053type AboutRow struct {
1054 OwnerKind string
1055 OwnerID int64
1056 OwnerName string
1057 About string
1058 Format string
1059}
1060
1061// PendingAboutBackfill lists the owners whose about text has not been
1062// written to a repository yet, resolving each one's name.
1063func (s *Store) PendingAboutBackfill() ([]AboutRow, error) {
1064 rows, err := s.DB.Query(`
1065 SELECT b.owner_kind, b.owner_id, b.about, b.about_format,
1066 COALESCE(u.username, o.name)
1067 FROM profile_about_backfill b
1068 LEFT JOIN users u ON b.owner_kind = 'user' AND u.id = b.owner_id
1069 LEFT JOIN orgs o ON b.owner_kind = 'org' AND o.id = b.owner_id
1070 ORDER BY b.owner_kind, b.owner_id`)
1071 if err != nil {
1072 return nil, err
1073 }
1074 defer rows.Close()
1075 var out []AboutRow
1076 for rows.Next() {
1077 var r AboutRow
1078 var name *string
1079 if err := rows.Scan(&r.OwnerKind, &r.OwnerID, &r.About, &r.Format, &name); err != nil {
1080 return nil, err
1081 }
1082 if name == nil {
1083 continue // the owner is gone; the row goes with them
1084 }
1085 r.OwnerName = *name
1086 out = append(out, r)
1087 }
1088 return out, rows.Err()
1089}
1090
1091// ClearAboutBackfill drops one owner's row once its file exists.
1092func (s *Store) ClearAboutBackfill(kind string, id int64) error {
1093 _, err := s.DB.Exec(
1094 "DELETE FROM profile_about_backfill WHERE owner_kind = ? AND owner_id = ?", kind, id)
1095 return err
1096}
1097```
1098
1099- [ ] **Step 8: Write the one-shot**
1100
1101Create `cmd/gitbayd/adminabout.go`:
1102
1103```go
1104package main
1105
1106import (
1107 "fmt"
1108
1109 "github.com/spf13/cobra"
1110
1111 "gitbay.org/gitbay/internal/config"
1112 "gitbay.org/gitbay/internal/control"
1113 "gitbay.org/gitbay/internal/gitutil"
1114 "gitbay.org/gitbay/internal/store"
1115)
1116
1117// adminMigrateProfileAboutCmd drains profile_about_backfill: each
1118// owner's parked about text becomes profile/README.* in <owner>/.gitbay.
1119// Idempotent — an owner who already has the file keeps it and loses the
1120// row.
1121func adminMigrateProfileAboutCmd() *cobra.Command {
1122 return &cobra.Command{
1123 Use: "migrate-profile-about",
1124 Short: "write parked profile about text into each owner's .gitbay repository",
1125 RunE: func(cmd *cobra.Command, args []string) error {
1126 cfg, err := config.Load(configPath)
1127 if err != nil {
1128 return err
1129 }
1130 st, err := openStore(cfg)
1131 if err != nil {
1132 return err
1133 }
1134 defer st.Close()
1135 rows, err := st.PendingAboutBackfill()
1136 if err != nil {
1137 return err
1138 }
1139 n := 0
1140 for _, row := range rows {
1141 written, err := writeAbout(cfg, st, row)
1142 if err != nil {
1143 return fmt.Errorf("%s: %w", row.OwnerName, err)
1144 }
1145 if err := st.ClearAboutBackfill(row.OwnerKind, row.OwnerID); err != nil {
1146 return err
1147 }
1148 if written {
1149 n++
1150 }
1151 }
1152 fmt.Printf("wrote %d profile about file(s)\n", n)
1153 return nil
1154 },
1155 }
1156}
1157
1158// writeAbout creates <owner>/.gitbay if it does not exist and commits
1159// the about at the recorded format. It reports whether it wrote
1160// anything: an owner who already has the file is left alone.
1161func writeAbout(cfg *config.Config, st *store.Store, row store.AboutRow) (bool, error) {
1162 path := row.OwnerName + "/" + control.ProfileRepoName
1163 repo, err := st.RepoByPath(path)
1164 if err != nil {
1165 id, cerr := st.CreateRepo(row.OwnerKind, row.OwnerID, control.ProfileRepoName, "public")
1166 if cerr != nil {
1167 return false, cerr
1168 }
1169 dir := control.RepoDir(cfg.Server.Root, row.OwnerName, control.ProfileRepoName)
1170 if ierr := gitutil.InitBare(dir, "main", control.HooksDir(cfg.Server.Root)); ierr != nil {
1171 st.DeleteRepo(id)
1172 return false, ierr
1173 }
1174 if repo, err = st.RepoByPath(path); err != nil {
1175 return false, err
1176 }
1177 }
1178 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name)
1179 ext := ".md"
1180 if row.Format == "org" {
1181 ext = ".org"
1182 }
1183 file := control.AboutBase + ext
1184 if _, err := gitutil.ReadBlob(dir, repo.DefaultBranch, file, 1); err == nil {
1185 return false, nil // already there
1186 }
1187 email := row.OwnerName + "@users.noreply." + cfg.SiteHost()
1188 if row.OwnerKind == "user" {
1189 if addr, _ := st.PrimaryVerifiedEmail(row.OwnerID); addr != "" {
1190 email = addr
1191 }
1192 }
1193 _, err = gitutil.CommitFileChange(dir, repo.DefaultBranch, file,
1194 []byte(row.About), row.OwnerName, email, "move profile about out of the database")
1195 return err == nil, err
1196}
1197```
1198
1199Confirm `cfg.SiteHost()`, `control.HooksDir`, `st.PrimaryVerifiedEmail`
1200and `st.DeleteRepo` exist with those names:
1201`grep -rn 'func.*SiteHost\|func HooksDir\|func (s \*Store) PrimaryVerifiedEmail\|func (s \*Store) DeleteRepo' internal/ | head`.
1202Adjust the calls to what is actually there rather than adding shims.
1203
1204- [ ] **Step 9: Register the subcommand**
1205
1206In `cmd/gitbayd/main.go`, in the `admin.AddCommand(` list that already
1207contains `adminMigrateCommitRefsCmd(),` (around line 401), add:
1208
1209```go
1210 adminMigrateProfileAboutCmd(),
1211```
1212
1213- [ ] **Step 10: Run the tests**
1214
1215Run: `go build ./... && go vet ./... && go test ./internal/store/ && go test ./e2e/ -run 'TestMigrateProfileAbout|TestProfile|TestDotRepos' -v -timeout 15m`
1216Expected: PASS.
1217
1218- [ ] **Step 11: Commit**
1219
1220```bash
1221git add internal/store/migrations/0058_profile_about_out.up.sql \
1222 internal/store/migrations/0058_profile_about_out.down.sql \
1223 internal/store/aboutbackfill.go internal/store/orgs.go \
1224 cmd/gitbayd/adminabout.go cmd/gitbayd/main.go \
1225 e2e/aboutbackfill_test.go
1226git commit -m "store: move the parked about text into each owner's .gitbay
1227
1228Ref #236"
1229```
1230
1231---
1232
1233### Task 7: Documentation
1234
1235**Files:**
1236- Modify: `.gitbay/wiki/Parity.md` (the profile row)
1237- Modify: `.gitbay/wiki/Users.md` (the profile section)
1238- Modify: `CHANGELOG.org`
1239
1240**Interfaces:**
1241- Consumes: everything above.
1242- Produces: nothing.
1243
1244- [ ] **Step 1: Find the rows to change**
1245
1246Run: `grep -n 'about\|profile' .gitbay/wiki/Parity.md .gitbay/wiki/Users.md | head -30`
1247
1248- [ ] **Step 2: Update Parity**
1249
1250The profile row's capability changes: the about is no longer a
1251`profile set` flag. Add or amend a row reading that the about text is
1252`profile/README.{md,org}` in `<owner>/.gitbay`, written by push or
1253`repo commit-file`, readable on every surface. Keep the table's existing
1254column order and marker vocabulary — read the surrounding rows first and
1255match them.
1256
1257- [ ] **Step 3: Update Users**
1258
1259In the profile section, replace the `--about` documentation with where
1260the file lives, the resolution order (`.md`, `.org`, `.markdown`), that
1261a private `.gitbay` keeps the about private, and that `.gitbay` does not
1262appear in explore or on the profile's repository list. Match the page's
1263existing voice.
1264
1265- [ ] **Step 4: Update the changelog**
1266
1267Add an entry under the current unreleased heading in `CHANGELOG.org`,
1268matching the file's existing entry style:
1269
1270```
1271- Profile about text moved into =profile/README.{md,org}= on the default
1272 branch of =<owner>/.gitbay=. It is written by a push or
1273 =repo commit-file=; =profile set --about= is gone. Run
1274 =gitbayd admin migrate-profile-about= after upgrading to write each
1275 owner's existing text into their repository. Repository names may now
1276 start with a dot, and dot-repos stay out of explore and profile
1277 listings.
1278```
1279
1280- [ ] **Step 5: Run the full local check**
1281
1282Run: `go build ./... && go vet ./... && go test ./internal/... `
1283Expected: PASS. The full e2e suite belongs to CI on bay1.
1284
1285- [ ] **Step 6: Commit**
1286
1287```bash
1288git add .gitbay/wiki/Parity.md .gitbay/wiki/Users.md CHANGELOG.org
1289git commit -m "docs: the profile about text lives in a repository
1290
1291Closes #236"
1292```
1293
1294---
1295
1296## Notes for whoever runs the deploy
1297
1298The migration and the backfill are one release but two steps. After
1299`make deploy` has restarted `gitbayd` (which runs `MigrateUp`), run:
1300
1301```bash
1302ssh -p 2222 root@gitbay.org gitbayd admin migrate-profile-about
1303```
1304
1305Until it runs, profiles that had an about show none. The holding table
1306keeps the text, so nothing is lost in between.