Commit b924e88c83

b924e88c83b49d63dec7f0568fa4fae62d7764d3

parent: 902739d68c

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 00:06 UTC

docs: implementation plan for the profile about move

Ref #236
docs/plans/2026-09-19-profile-about-repo.md added +1306
@@ -0,0 +1,1306 @@
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.
docs/specs/2026-09-19-profile-about-repo-design.md +23
@@ -54,6 +54,11 @@ and meanings; only the source changes. `AboutFormat` is `org` for a
5454`.org` file and `md` otherwise. The API contract and the iOS client are
5555untouched.
5656
57One field is added: `about_path`, the repository-relative path the text
58was read from, empty when there is no about. The web needs it to link to
59the file rather than guess its extension, and every other client gets
60the same pointer.
61
5762`aboutHTML` in `internal/httpd/web.go` becomes a direct
5863`renderReadme(name, raw)` call — there is a filename to dispatch on now,
5964so the stored-format indirection goes away.
@@ -76,6 +81,21 @@ Relaxing the pattern rather than whitelisting the one name `.gitbay` is
7681the smaller change, and it gives owners `.dotfiles` and the like for
7782free.
7883
84## A first commit into an empty repository
85
86`gitutil.CommitFileChange` resolves the target branch and fails when it
87does not exist, so committing the first file into a freshly created
88`.gitbay` is impossible today. Both the web's create button and the
89backfill need it to work.
90
91An unresolvable branch becomes a root commit **only when the repository
92has no refs at all**. Anywhere else it stays the error it is now — a
93typo'd branch name in a repository with history must not silently start
94an orphan branch.
95
96This also makes `repo commit-file` work on a repository created but
97never pushed to, which is the same gap seen from the CLI.
98
7999## Writing
80100
81101There is no about-specific write command, for the reason the wiki has
@@ -175,6 +195,9 @@ Unit:
175195- `ownerAbout`: `.md` wins over `.org`; a missing repo, a missing
176196 branch and a missing file each give an empty about; a repo the caller
177197 cannot read gives an empty about.
198- `gitutil.CommitFileChange`: the first commit into an empty repository
199 succeeds and reads back; the second takes the parented path; an
200 unknown branch in a repository with history is still an error.
178201
179202e2e, new `e2e/profileabout_test.go`:
180203