docs/plans/2026-09-19-profile-about-repo.md

v1.32.0
gitbay/docs/plans/2026-09-19-profile-about-repo.md rendered · source · history · blame · raw

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.