# Profile about in a repository — implementation plan > **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. **Goal:** Move the user/org profile about text out of the `users`/`orgs` columns and into `profile/README.{md,org}` on the default branch of a repository named `.gitbay` under the owner's namespace. **Architecture:** The about becomes a file, read the way wiki pages are read — no store rows, access derived from the parent repository. The control command's JSON keeps its `about`/`about_format` fields, so the API and the iOS client do not move. Writes stop going through `profile set`; the file is written by a push or `repo commit-file`. A SQL migration parks the existing text in a holding table and drops the columns; a `gitbayd admin` one-shot drains the table into repositories. **Tech Stack:** Go, SQLite (modernc.org/sqlite), `git` subprocesses via `internal/gitutil`, `html/template`. **Spec:** `docs/specs/2026-09-19-profile-about-repo-design.md` ## Global Constraints - Repository name `.gitbay`; file `profile/README` plus one of `.md`, `.org`, `.markdown`, resolved in that order. - `ProfileOut.About` and `ProfileOut.AboutFormat` keep their JSON names. `AboutFormat` is `org` for a `.org` file, `md` otherwise. - A repository the caller cannot read yields an empty about, never an error — the private-repo rule is 404-shaped, and a profile must not confirm a namespace. - Blob reads are capped at `maxCommitFileBytes` (1MB), already defined in `internal/control/commitfile.go`. - Never attribute anything to an assistant or model, anywhere. - Commit messages reference the issue: `Ref #236`, and the last one `Closes #236`. - Branch is `profile-about-236`; never push to `main`. --- ### Task 1: Repository names may start with a dot `.gitbay` is an invalid repository name today: `namePat` requires a leading alphanumeric. Relax it, and close the hole that lets a repository be named exactly `.git`. **Files:** - Modify: `internal/policy/names.go:37` (namePat), `:52-69` (ValidateName) - Test: `internal/policy/names_test.go` **Interfaces:** - Consumes: nothing. - Produces: `policy.ValidateName(name string) error` accepts a single leading dot. Task 6 relies on `.gitbay` validating. - [ ] **Step 1: Write the failing test** Append to `internal/policy/names_test.go`: ```go func TestValidateNameLeadingDot(t *testing.T) { for _, name := range []string{".gitbay", ".dotfiles", ".a"} { if err := ValidateName(name); err != nil { t.Errorf("ValidateName(%q) = %v, want nil", name, err) } } for _, name := range []string{".", "..", ".git", "repo.git", "..a", ".-a"} { if err := ValidateName(name); err == nil { t.Errorf("ValidateName(%q) = nil, want error", name) } } // The ceiling is 63 characters, dot included. if err := ValidateName("." + strings.Repeat("a", 62)); err != nil { t.Errorf("63-character dotted name rejected: %v", err) } if err := ValidateName("." + strings.Repeat("a", 63)); err == nil { t.Error("64-character dotted name accepted") } } ``` Add `"strings"` to that file's imports if it is not already there. - [ ] **Step 2: Run it and watch it fail** Run: `go test ./internal/policy/ -run TestValidateNameLeadingDot -v` Expected: FAIL — `ValidateName(".gitbay")` returns an invalid-name error. - [ ] **Step 3: Relax the pattern** In `internal/policy/names.go`, replace the `namePat` declaration and its comment: ```go // namePat matches valid user, org, and repo names: lowercase alphanumerics, // dot, dash, underscore; must start with an alphanumeric, or with a single // dot before one. A leading dot marks a repository as infrastructure rather // than a project — .gitbay holds an owner's profile content. Dots are // further restricted by ValidateName to avoid "." / ".." and ".git". var namePat = regexp.MustCompile(`^\.?[a-z0-9][a-z0-9._-]{0,61}$`) ``` - [ ] **Step 4: Refuse `.git` exactly, not just as a suffix** In `ValidateName`, replace the suffix check: ```go if len(name) > 4 && name[len(name)-4:] == ".git" { return fmt.Errorf("invalid name %q: must not end in .git", name) } ``` with: ```go // A name of exactly ".git" is now reachable through the leading-dot // rule, and a bare .git directory in the namespace is not a thing to // allow; HasSuffix covers both it and "repo.git". if strings.HasSuffix(name, ".git") { return fmt.Errorf("invalid name %q: must not end in .git", name) } ``` `strings` is already imported there. - [ ] **Step 5: Run the package's tests** Run: `go test ./internal/policy/` Expected: PASS, including the pre-existing `TestValidateName`. - [ ] **Step 6: Commit** ```bash git add internal/policy/names.go internal/policy/names_test.go git commit -m "policy: a repository name may start with a dot Ref #236" ``` --- ### Task 2: A first commit into an empty repository `CommitFileChange` resolves the branch and fails when it does not exist, so committing the first file into a freshly created `.gitbay` is impossible. Task 4's web button and Task 6's backfill both need it. Allow a root commit **only when the repository has no refs at all**, so that a typo'd branch name in a repository with history still fails the way it does today rather than silently starting an orphan branch. **Files:** - Modify: `internal/gitutil/merge.go:188-239` (CommitFileChange) - Test: `internal/gitutil/merge_test.go` (create if absent) **Interfaces:** - Consumes: `gitutil.ResolveRef(dir, ref) (string, error)`, `gitutil.CommitTree(dir, tree string, parents []string, name, email, message string) (string, error)`, `gitutil.UpdateRefCAS(dir, ref, newSHA, oldSHA string) error`. - Produces: `gitutil.CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error)` — unchanged signature, now succeeding on an empty repository. - [ ] **Step 1: Write the failing test** Create or append to `internal/gitutil/merge_test.go`: ```go func TestCommitFileChangeEmptyRepo(t *testing.T) { dir := t.TempDir() if err := InitBare(dir, "main", ""); err != nil { t.Fatal(err) } sha, err := CommitFileChange(dir, "main", "profile/README.md", []byte("# hello\n"), "alice", "alice@example.org", "add about") if err != nil { t.Fatalf("first commit into an empty repository: %v", err) } if sha == "" { t.Fatal("no sha returned") } raw, err := ReadBlob(dir, "main", "profile/README.md", 1<<20) if err != nil { t.Fatalf("reading it back: %v", err) } if string(raw) != "# hello\n" { t.Errorf("read back %q", raw) } // A second commit still takes the normal parented path. if _, err := CommitFileChange(dir, "main", "profile/README.md", []byte("# hello again\n"), "alice", "alice@example.org", "edit"); err != nil { t.Fatalf("second commit: %v", err) } // A branch that does not exist in a repository that has history is // still an error, not a new orphan branch. if _, err := CommitFileChange(dir, "nope", "x.md", []byte("x"), "alice", "alice@example.org", "x"); err == nil { t.Error("committing to an unknown branch of a non-empty repository succeeded") } } ``` Check `InitBare`'s signature in `internal/gitutil` before running; if its third parameter is not an optional hooks directory, pass what the existing callers in `internal/control/repo.go:217` pass. - [ ] **Step 2: Run it and watch it fail** Run: `go test ./internal/gitutil/ -run TestCommitFileChangeEmptyRepo -v` Expected: FAIL — `branch main: unknown ref "refs/heads/main"`. - [ ] **Step 3: Add the unborn-branch path** In `internal/gitutil/merge.go`, replace the opening of `CommitFileChange`: ```go branchRef := "refs/heads/" + branch parent, err := ResolveRef(dir, branchRef) if err != nil { return "", fmt.Errorf("branch %s: %w", branch, err) } ``` with: ```go branchRef := "refs/heads/" + branch parent, err := ResolveRef(dir, branchRef) if err != nil { // An unborn branch is only a root commit in a repository with no // refs at all. Anywhere else an unresolvable branch is a typo, and // starting an orphan branch for it would be worse than refusing. if !isEmptyRepo(dir) { return "", fmt.Errorf("branch %s: %w", branch, err) } parent = "" } ``` Replace the `read-tree` block: ```go rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}") rt.Env = env if out, err := rt.CombinedOutput(); err != nil { return "", fmt.Errorf("read-tree: %v\n%s", err, out) } ``` with: ```go arg := parent + "^{tree}" if parent == "" { arg = "--empty" } rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", arg) rt.Env = env if out, err := rt.CombinedOutput(); err != nil { return "", fmt.Errorf("read-tree: %v\n%s", err, out) } ``` Replace the `CommitTree` call: ```go sha, err := CommitTree(dir, tree, []string{parent}, name, email, message) ``` with: ```go var parents []string if parent != "" { parents = []string{parent} } sha, err := CommitTree(dir, tree, parents, name, email, message) ``` `UpdateRefCAS` already omits the old value when `parent` is empty, so the tail of the function is unchanged. - [ ] **Step 4: Add the emptiness check** Add below `CommitFileChange` in the same file: ```go // isEmptyRepo reports whether dir has no refs at all — a repository // created but never pushed to. func isEmptyRepo(dir string) bool { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "-n1", "--all").Output() return err == nil && strings.TrimSpace(string(out)) == "" } ``` - [ ] **Step 5: Run the tests** Run: `go test ./internal/gitutil/` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add internal/gitutil/merge.go internal/gitutil/merge_test.go git commit -m "gitutil: commit-file writes the first commit of an empty repository Ref #236" ``` --- ### Task 3: Read the about from the repository **Files:** - Modify: `internal/control/profile.go` (constants, `ownerAbout`, `ProfileOut`, `runProfileShow`) - Modify: `internal/httpd/web.go` (`aboutHTML`, the profile handler at ~446) - Test: `e2e/profileabout_test.go` (create) **Interfaces:** - Consumes: `control.RepoDir(root, owner, name) string`, `gitutil.ReadBlob(dir, ref, path string, limit int64) ([]byte, error)`, `policy.CanRead(u store.User, r store.Repo, grant string) bool`, `c.Store.RepoByPath(path) (store.Repo, error)`, `c.Store.AccessRole(repoID, userID int64) (string, error)`, `maxCommitFileBytes` from `internal/control/commitfile.go`. - Produces: - `const ProfileRepoName = ".gitbay"` and `const AboutBase = "profile/README"` in `internal/control/profile.go` — Task 4, 5 and 6 use them. - `func ownerAbout(c *Ctx, owner string) (text, format, path string)`. - `ProfileOut.AboutPath string \`json:"about_path,omitempty"\`` — Task 4's template links to it. - `func aboutHTML(text, format string) template.HTML` in `internal/httpd/web.go`. - [ ] **Step 1: Write the failing e2e test** Create `e2e/profileabout_test.go`: ```go package e2e import ( "strings" "testing" ) // The about text is a file in /.gitbay, read on every surface // with the reader's own access. func TestProfileAboutFromRepo(t *testing.T) { inst := startInstance(t) aliceKey := inst.newKey(t, "alice") inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") bobKey := inst.newKey(t, "bob") inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay"); code != 0 { t.Fatal("creating alice/.gitbay failed") } if _, _, code := inst.ssh(t, aliceKey, "# alice\n\nhello from a file\n", "repo", "commit-file", "alice/.gitbay", "profile/README.md", "--ref", "main", "--file", "-"); code != 0 { t.Fatal("committing the about failed") } out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") if code != 0 { t.Fatalf("profile show: %d", code) } if !strings.Contains(out, "hello from a file") { t.Errorf("about not read from the repository: %s", out) } if !strings.Contains(out, `"about_format":"md"`) { t.Errorf("about_format not md: %s", out) } if !strings.Contains(out, `"about_path":"profile/README.md"`) { t.Errorf("about_path missing: %s", out) } _, body := inst.get(t, "/alice") if !strings.Contains(body, "hello from a file") { t.Error("web profile does not render the about") } } // .org wins nothing over .md, and a private .gitbay keeps the about to // the people who can read it. func TestProfileAboutFormatAndPrivacy(t *testing.T) { inst := startInstance(t) aliceKey := inst.newKey(t, "alice") inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") bobKey := inst.newKey(t, "bob") inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay", "--private") inst.ssh(t, aliceKey, "* heading\n\norg text here\n", "repo", "commit-file", "alice/.gitbay", "profile/README.org", "--ref", "main", "--file", "-") out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") if !strings.Contains(out, "org text here") || !strings.Contains(out, `"about_format":"org"`) { t.Errorf("owner cannot read their own private about: %s", out) } out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") if code != 0 { t.Fatalf("profile show for an outsider should succeed: %d", code) } if strings.Contains(out, "org text here") { t.Errorf("private about leaked to an outsider: %s", out) } // A .md beside the .org wins: it is first in the resolution order. inst.ssh(t, aliceKey, "markdown wins\n", "repo", "commit-file", "alice/.gitbay", "profile/README.md", "--ref", "main", "--file", "-") out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") if !strings.Contains(out, "markdown wins") { t.Errorf(".md did not win resolution: %s", out) } } ``` - [ ] **Step 2: Run them and watch them fail** Run: `go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m` Expected: FAIL — `repo create alice/.gitbay` succeeds after Task 1, but `profile show` reports no about, and `about_path` is absent. - [ ] **Step 3: Add the resolution helper** In `internal/control/profile.go`, after the `maxProfileLinks` constant: ```go // ProfileRepoName is the repository that holds an owner's profile // content. A dot-repo because it is infrastructure rather than a // project: later per-owner configuration goes beside the about text, // and the leading dot keeps it out of listings. const ProfileRepoName = ".gitbay" // AboutBase is the about file's path in that repository, without its // extension. const AboutBase = "profile/README" // aboutExts are the formats the about is read from, in resolution // order — the wiki's order, for the same reason. var aboutExts = []string{".md", ".org", ".markdown"} // ownerAbout reads an owner's about text from /.gitbay. Anything // missing — the repository, the branch, the file — is an empty about, // and so is a repository this caller cannot read: a profile must not // confirm a private namespace. path is the file it came from, so a // client can link to it. func ownerAbout(c *Ctx, owner string) (text, format, path string) { repo, err := c.Store.RepoByPath(owner + "/" + ProfileRepoName) if err != nil { return "", "", "" } grant, err := c.Store.AccessRole(repo.ID, c.User.ID) if err != nil || !policy.CanRead(c.User, repo, grant) { return "", "", "" } dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) for _, ext := range aboutExts { raw, err := gitutil.ReadBlob(dir, repo.DefaultBranch, AboutBase+ext, maxCommitFileBytes) if err != nil || len(raw) == 0 { continue } f := "md" if ext == ".org" { f = "org" } return string(raw), f, AboutBase + ext } return "", "", "" } ``` - [ ] **Step 4: Add `AboutPath` and read through the helper** In `ProfileOut`, below `AboutFormat`: ```go // AboutPath is where the about was read from in /.gitbay, so a // client can link to the file rather than guess its extension. AboutPath string `json:"about_path,omitempty"` ``` In `runProfileShow`, replace the `d := ProfileOut{...}` literal: ```go about, aboutFormat, aboutPath := ownerAbout(c, name) d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website, About: about, AboutFormat: aboutFormat, AboutPath: aboutPath, Links: p.Links, Repos: []ProfileRepo{}} ``` - [ ] **Step 5: Render from the text, not from a store struct** In `internal/httpd/web.go`, replace `aboutHTML`: ```go // aboutHTML renders a profile's about text. The format comes from the // file it was read from: org is org, anything else markdown. func aboutHTML(text, format string) template.HTML { if strings.TrimSpace(text) == "" { return "" } name := "about.md" if format == "org" { name = "about.org" } return renderReadme(name, []byte(text)) } ``` In the profile handler near line 446, drop the about from the `store.Profile` literal: ```go profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} ``` and update the `AboutHTML` field's value in the `s.render` struct literal to `aboutHTML(d.About, d.AboutFormat)`. Find its current call with `grep -n 'aboutHTML' internal/httpd/web.go` and change that argument list. - [ ] **Step 6: Build, vet, and run the tests** Run: `go build ./... && go vet ./... && go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m` Expected: PASS. `go vet` matters here — `aboutHTML`'s signature changed and `go build` does not compile `_test.go` callers. - [ ] **Step 7: Commit** ```bash git add internal/control/profile.go internal/httpd/web.go e2e/profileabout_test.go git commit -m "profile: read the about text from /.gitbay Ref #236" ``` --- ### Task 4: Stop writing the about through `profile set` There is no about-specific write command, for the reason the wiki has none: the content is a file, written the way files are written. **Files:** - Modify: `internal/control/profile.go` (`register` usages, `profileEdit`, `parseProfileFlags`, `applyProfile`, `runProfileSet`, `runOrgProfile`) - Modify: `internal/httpd/account.go:42-87` (page struct), `:251-266` (the `profile` form case) - Modify: `internal/web/templates/account.html:25,34-36` - Modify: `internal/httpd/routes.go` if a new form case needs no route (it does not — `/settings` already takes the POST) - Test: `e2e/profileabout_test.go` (append) **Interfaces:** - Consumes: `control.ProfileRepoName`, `control.AboutBase` from Task 3; `ProfileOut.AboutPath` from Task 3; `s.runControl(u store.User, argv []string) (int, string, bool)` and `s.runControlStdin(u store.User, argv []string, stdin string) (string, bool)` in `internal/httpd` — confirm their exact signatures with `grep -n 'func (s \*Server) runControl' internal/httpd/*.go` before use. - Produces: `profile set` and `org profile` with no `--about`, `--about-format` or `--file`, and `ReadsStdin` unset. - [ ] **Step 1: Write the failing test** Append to `e2e/profileabout_test.go`: ```go // The about is not settable through profile set any more: it is a file. func TestProfileSetHasNoAbout(t *testing.T) { inst := startInstance(t) aliceKey := inst.newKey(t, "alice") inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "'inline text'") if code == 0 { t.Error("profile set --about still accepted") } // The flags that stay still work. if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'a line'", "--link", "'site|https://example.org'"); code != 0 { t.Fatalf("profile set --description --link: %d", code) } out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") if !strings.Contains(out, "a line") || !strings.Contains(out, "https://example.org") { t.Errorf("description or link not saved: %s", out) } } ``` - [ ] **Step 2: Run it and watch it fail** Run: `go test ./e2e/ -run TestProfileSetHasNoAbout -v -timeout 10m` Expected: FAIL — `profile set --about` exits 0. - [ ] **Step 3: Drop the flags from the command registrations** In `internal/control/profile.go`'s `init`, replace the two registrations: ```go register(Command{Path: []string{"profile", "set"}, Summary: "set your profile", Usage: "profile set [--description ] [--website ] [--link ]... ('' clears)", Run: runProfileSet}) register(Command{Path: []string{"org", "profile"}, Summary: "show or set an org's profile", Usage: "org profile [--description ] [--website ] [--link ]...", Run: runOrgProfile}) ``` `ReadsStdin` is gone from both; `TestStdinCommandsReadStdin` enforces that a command that no longer reads stdin does not claim to. - [ ] **Step 4: Drop the fields from the edit struct and the parser** In `profileEdit`, delete the `About` and `AboutFormat` fields. Update `empty()`: ```go func (e profileEdit) empty() bool { return e.Description == nil && e.Website == nil && e.Links == nil } ``` In `parseProfileFlags`, delete the `about`, `file` and `sawAbout` locals, the `--about` / `--about-format` / `--file` entries from `flagSpec.Values`, the `--about-format` case from the loop over the value flags, the two `if f.Has(...)` blocks that set them, and the `if sawAbout { ... bodyFrom ... }` block. The signature keeps its `*Ctx` parameter — `parseFlags` errors still flow through `c` in the callers — but if the compiler reports `c` unused, rename it to `_` in the parameter list and update both call sites. In `applyProfile`, delete the `e.About` and `e.AboutFormat` blocks. In `runProfileSet`, change the "nothing to set" message: ```go return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website and/or --link") ``` In both `runProfileSet` and `runOrgProfile`, drop `About:` and `AboutFormat:` from the `ProfileOut` literals they emit. - [ ] **Step 5: Build and fix what falls out** Run: `go build ./... && go vet ./...` Expected: errors in `internal/httpd/account.go` and possibly `internal/control/migrate.go`. `internal/control/migrate.go` uses `store.Profile` as a whole and needs no change until Task 6. Fix only `account.go` here, per Step 6. - [ ] **Step 6: Point the account page at the file** In `internal/httpd/account.go`, in the `profile` case of the settings form handler, replace the whole case body: ```go case "profile": argv := []string{"profile", "set", "--description", r.FormValue("description"), "--website", r.FormValue("website"), } for _, link := range profileLinkArgs(r.FormValue("links")) { argv = append(argv, "--link", link) } if _, msg, ok := s.runControl(u, argv); !ok { back(msg, "") return } back("", "profile updated") case "profile-repo": // The about text is a file. Create the repository that holds it and // commit a starter README, so the file editor has a branch to open. path := u.Username + "/" + control.ProfileRepoName if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok { back(msg, "") return } starter := "# " + u.Username + "\n\nThis is your profile's about text.\n" if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path, control.AboutBase + ".md", "--ref", "main", "--message", "add profile about", "--file", "-"}, starter); !ok { back(msg, "") return } back("", "profile repository created") ``` `runControl`'s return shape is `(code int, msg string, ok bool)` in the `theme` case above — match it exactly. In `accountPage`, add two fields to the anonymous page struct after `LinksText`: ```go AboutRepo string // "/.gitbay", the repository that holds the about AboutEdit string // the file editor's URL, empty when the repository has no about yet ``` and compute them before `s.render`: ```go aboutRepo := u.Username + "/" + control.ProfileRepoName aboutEdit := "" if profile.AboutPath != "" { aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath } ``` then add `aboutRepo, aboutEdit` to the struct literal's value list in the same position as the fields. - [ ] **Step 7: Replace the textarea with the pointer** In `internal/web/templates/account.html`, delete line 25 (`{{if .Draft.Is "about"}}...{{end}}`), the About `