docs/plans/2026-09-11-snippets.md
1910 lines · 71051 bytes
Snippets: 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: A snippet a user owns, shares by URL, and edits in place: one or more named text files, a description, and a visibility. Closes #195.
Architecture: Two new tables (migration 0053) hold snippets and their files; content sits in SQLite as a BLOB. Eight snippet control commands in internal/control/snippet.go are the only write path; the CLI, the JSON API and the web forms dispatch into them. Read rules live in internal/policy/snippets.go. The web renders under /{owner}/-/snippets, the pattern /{owner}/-/labels set.
Tech Stack: Go, SQLite via modernc (hand-written SQL, no ORM), Go html/template, chroma through the existing highlight, the control registry in internal/control, the e2e harness in e2e/.
Spec: docs/specs/2026-09-11-snippets-design.md
Global Constraints
- Every capability lands as a control command first; the CLI, web and API dispatch into it. New commands need a
pass()row incmd/gitbay/main.go(TestCLIine2e/cli_test.goenforces this) and, ifReadOnly, a row inreadArgsine2e/readonly_test.go(TestReadOnlyCommandsWriteNothingenforces that). - A command that reads stdin sets
ReadsStdin: true, orcontrol.goswaps in an empty reader and--file -stores nothing without an error. - Hand-written SQL only. Migrations are
internal/store/migrations/NNNN_name.up.sqland.down.sql, embedded, run one per transaction;TestMigrateUpDownruns both directions. - Private things return not-found (exit 3, HTTP 404), never a denial that confirms they exist.
- Every
<input>and<textarea>a person types into carries anaria-label(internal/httpd/inputlabels_test.go). One<h1>per page. - Never mention an assistant or model anywhere: commit messages, comments, docs.
- Commit messages reference the issue:
Ref #195on each task,Closes #195on the last. - Run locally:
go build ./... && go vet ./...and the unit tests of the touched packages. Run at most the one e2e test you write (go test ./e2e -run 'TestSnippets$'); CI on bay1 runs the full suite. - Style: plain sentences in comments, no dramatic framing. Match the surrounding code. Comments in stores and handlers are one or two lines saying why, as the neighbours do.
- Work on branch
snippetsin the worktree../gitbay-snippets, which already holds the spec.
Names used across tasks, fixed here so the tasks agree:
- Store:
store.Snippet,store.SnippetFile,CreateSnippet,SnippetByPublicID,SnippetFiles,SnippetFile,ListSnippets,CountSnippets,UpdateSnippet,DeleteSnippet,SetSnippetFile,RemoveSnippetFile. - Policy:
policy.CanReadSnippet,policy.CanWriteSnippet. - Config:
Limits.MaxSnippetBytes(max_snippet_bytes, default1 << 20). - Control: exported
control.SnippetOut,control.SnippetFileOut; the constantmaxSnippetFiles = 64. - Web: handlers
snippetsPage,snippetPage,snippetRaw,snippetNewForm,snippetNewSubmit,snippetEditSubmit,snippetDeleteSubmit,snippetFileSubmit,snippetFileRemoveSubmit; templatessnippets.html,snippet.html,snippetnew.html.
Task 1: Migration 0053 and the store
Files:
- Create:
internal/store/migrations/0053_snippets.up.sql - Create:
internal/store/migrations/0053_snippets.down.sql - Create:
internal/store/snippets.go - Test:
internal/store/snippets_test.go
Interfaces:
- Produces the tables
snippetsandsnippet_filesas in the spec. - Produces:
type Snippet struct {
ID int64
PublicID string
OwnerID int64
OwnerName string
Description string
Visibility string // public | unlisted | private
CreatedAt string
UpdatedAt string
Files []SnippetFile // names and sizes; Content is filled by SnippetFiles and SnippetFile only
}
type SnippetFile struct {
Name string
Size int64
Content []byte
}
func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) // ErrExists on a public_id collision
func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) // ErrNotFound; Files carry Name and Size
func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) // with Content, by name
func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) // ErrNotFound
func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) // newest first; all=false is public only; afterID=0 from the start
func (s *Store) CountSnippets(ownerID int64, all bool) (int, error)
func (s *Store) UpdateSnippet(id int64, description, visibility string) error
func (s *Store) DeleteSnippet(id int64) error
func (s *Store) SetSnippetFile(id int64, name string, content []byte) error // insert or replace; touches updated_at
func (s *Store) RemoveSnippetFile(id int64, name string) error // ErrNotFound when absent; touches updated_at
- Step 1: Write the failing store test
Create internal/store/snippets_test.go:
package store
import (
"errors"
"testing"
)
func TestSnippets(t *testing.T) {
s := open(t)
alice, err := s.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
id, err := s.CreateSnippet(alice, "abcdef012345", "a log", "unlisted", "build.log", []byte("ok\n"))
if err != nil {
t.Fatal(err)
}
if _, err := s.CreateSnippet(alice, "abcdef012345", "", "public", "x", []byte("x")); !errors.Is(err, ErrExists) {
t.Fatalf("duplicate public id: %v", err)
}
sn, err := s.SnippetByPublicID("abcdef012345")
if err != nil {
t.Fatal(err)
}
if sn.ID != id || sn.OwnerName != "alice" || sn.Visibility != "unlisted" || sn.Description != "a log" {
t.Fatalf("snippet: %+v", sn)
}
if len(sn.Files) != 1 || sn.Files[0].Name != "build.log" || sn.Files[0].Size != 3 || sn.Files[0].Content != nil {
t.Fatalf("files on lookup: %+v", sn.Files)
}
// Set adds, then replaces; remove drops; the file read carries content.
if err := s.SetSnippetFile(id, "notes.txt", []byte("one\n")); err != nil {
t.Fatal(err)
}
if err := s.SetSnippetFile(id, "notes.txt", []byte("two\n")); err != nil {
t.Fatal(err)
}
f, err := s.SnippetFile(id, "notes.txt")
if err != nil || string(f.Content) != "two\n" || f.Size != 4 {
t.Fatalf("file after replace: %+v %v", f, err)
}
files, err := s.SnippetFiles(id)
if err != nil || len(files) != 2 || files[0].Name != "build.log" || string(files[1].Content) != "two\n" {
t.Fatalf("files: %+v %v", files, err)
}
if err := s.RemoveSnippetFile(id, "notes.txt"); err != nil {
t.Fatal(err)
}
if err := s.RemoveSnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
t.Fatalf("remove missing file: %v", err)
}
if _, err := s.SnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
t.Fatalf("read removed file: %v", err)
}
// Listing: public only unless all; newest first; keyset by id.
pub, err := s.CreateSnippet(alice, "000000000001", "", "public", "a", []byte("a"))
if err != nil {
t.Fatal(err)
}
if _, err := s.CreateSnippet(alice, "000000000002", "", "private", "b", []byte("b")); err != nil {
t.Fatal(err)
}
got, err := s.ListSnippets(alice, false, 0, 0)
if err != nil || len(got) != 1 || got[0].ID != pub {
t.Fatalf("public list: %+v %v", got, err)
}
got, err = s.ListSnippets(alice, true, 0, 0)
if err != nil || len(got) != 3 || got[0].PublicID != "000000000002" || got[2].ID != id {
t.Fatalf("all list: %+v %v", got, err)
}
got, err = s.ListSnippets(alice, true, 2, got[0].ID)
if err != nil || len(got) != 2 || got[0].ID != pub {
t.Fatalf("paged list: %+v %v", got, err)
}
if n, err := s.CountSnippets(alice, false); err != nil || n != 1 {
t.Fatalf("public count: %d %v", n, err)
}
if n, err := s.CountSnippets(alice, true); err != nil || n != 3 {
t.Fatalf("all count: %d %v", n, err)
}
// Update, delete, and the owner cascade.
if err := s.UpdateSnippet(id, "renamed", "public"); err != nil {
t.Fatal(err)
}
sn, _ = s.SnippetByPublicID("abcdef012345")
if sn.Description != "renamed" || sn.Visibility != "public" {
t.Fatalf("after update: %+v", sn)
}
if err := s.DeleteSnippet(id); err != nil {
t.Fatal(err)
}
if _, err := s.SnippetByPublicID("abcdef012345"); !errors.Is(err, ErrNotFound) {
t.Fatalf("after delete: %v", err)
}
if err := s.DeleteUser(alice); err != nil {
t.Fatal(err)
}
var n int
if err := s.DB.QueryRow("SELECT COUNT(*) FROM snippet_files").Scan(&n); err != nil || n != 0 {
t.Fatalf("files after user delete: %d %v", n, err)
}
}
- Step 2: Run it to see it fail
Run: go test ./internal/store -run TestSnippets
Expected: compile error, s.CreateSnippet undefined.
- Step 3: Write the migration
internal/store/migrations/0053_snippets.up.sql:
-- Snippets: named text files a user owns and shares by URL, outside any
-- repository. public_id is the opaque id in URLs and commands.
CREATE TABLE snippets (
id INTEGER PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
description TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX snippets_owner ON snippets(owner_id, id);
CREATE TABLE snippet_files (
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
name TEXT NOT NULL,
content BLOB NOT NULL,
size INTEGER NOT NULL,
PRIMARY KEY (snippet_id, name)
);
internal/store/migrations/0053_snippets.down.sql:
DROP TABLE snippet_files;
DROP TABLE snippets;
- Step 4: Write the store
Create internal/store/snippets.go:
package store
import (
"database/sql"
"errors"
)
type Snippet struct {
ID int64
PublicID string
OwnerID int64
OwnerName string
Description string
Visibility string // public | unlisted | private
CreatedAt string
UpdatedAt string
// Files carries names and sizes. Content is filled by SnippetFiles and
// SnippetFile only, so a listing does not read every body.
Files []SnippetFile
}
type SnippetFile struct {
Name string
Size int64
Content []byte
}
const snippetSelect = `
SELECT s.id, s.public_id, s.owner_id, u.username, s.description, s.visibility, s.created_at, s.updated_at
FROM snippets s JOIN users u ON u.id = s.owner_id`
func scanSnippet(row interface{ Scan(...any) error }) (Snippet, error) {
var sn Snippet
err := row.Scan(&sn.ID, &sn.PublicID, &sn.OwnerID, &sn.OwnerName, &sn.Description, &sn.Visibility, &sn.CreatedAt, &sn.UpdatedAt)
return sn, err
}
// CreateSnippet inserts the snippet and its first file in one transaction.
// A public_id collision is ErrExists so the caller can draw another.
func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) {
tx, err := s.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT INTO snippets (public_id, owner_id, description, visibility) VALUES (?, ?, ?, ?)",
publicID, ownerID, description, visibility)
if err != nil {
if isUniqueErr(err) {
return 0, ErrExists
}
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if _, err := tx.Exec("INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)",
id, name, content, len(content)); err != nil {
return 0, err
}
return id, tx.Commit()
}
func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) {
sn, err := scanSnippet(s.DB.QueryRow(snippetSelect+" WHERE s.public_id = ?", publicID))
if errors.Is(err, sql.ErrNoRows) {
return sn, ErrNotFound
}
if err != nil {
return sn, err
}
sn.Files, err = s.snippetFileNames(sn.ID)
return sn, err
}
func (s *Store) snippetFileNames(id int64) ([]SnippetFile, error) {
rows, err := s.DB.Query("SELECT name, size FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SnippetFile
for rows.Next() {
var f SnippetFile
if err := rows.Scan(&f.Name, &f.Size); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// SnippetFiles returns every file with its content, by name.
func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) {
rows, err := s.DB.Query("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SnippetFile
for rows.Next() {
var f SnippetFile
if err := rows.Scan(&f.Name, &f.Size, &f.Content); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) {
var f SnippetFile
err := s.DB.QueryRow("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name).
Scan(&f.Name, &f.Size, &f.Content)
if errors.Is(err, sql.ErrNoRows) {
return f, ErrNotFound
}
return f, err
}
// ListSnippets lists an owner's snippets newest first. all=false keeps
// public ones only. afterID is the keyset cursor: rows older than it.
// Ids grow with creation, so ordering by id is creation order.
func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) {
q := snippetSelect + " WHERE s.owner_id = ?"
args := []any{ownerID}
if !all {
q += " AND s.visibility = 'public'"
}
if afterID > 0 {
q += " AND s.id < ?"
args = append(args, afterID)
}
q += " ORDER BY s.id DESC"
if limit > 0 {
q += " LIMIT ?"
args = append(args, limit)
}
rows, err := s.DB.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Snippet
for rows.Next() {
sn, err := scanSnippet(rows)
if err != nil {
return nil, err
}
out = append(out, sn)
}
if err := rows.Err(); err != nil {
return nil, err
}
// One query per row for the names; pages are at most 200 rows.
for i := range out {
if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
return nil, err
}
}
return out, nil
}
func (s *Store) CountSnippets(ownerID int64, all bool) (int, error) {
q := "SELECT COUNT(*) FROM snippets WHERE owner_id = ?"
if !all {
q += " AND visibility = 'public'"
}
var n int
err := s.DB.QueryRow(q, ownerID).Scan(&n)
return n, err
}
func (s *Store) UpdateSnippet(id int64, description, visibility string) error {
_, err := s.DB.Exec(
"UPDATE snippets SET description = ?, visibility = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
description, visibility, id)
return err
}
func (s *Store) DeleteSnippet(id int64) error {
_, err := s.DB.Exec("DELETE FROM snippets WHERE id = ?", id)
return err
}
// SetSnippetFile adds the file or replaces one of the same name.
func (s *Store) SetSnippetFile(id int64, name string, content []byte) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)
ON CONFLICT (snippet_id, name) DO UPDATE SET content = excluded.content, size = excluded.size`,
id, name, content, len(content)); err != nil {
return err
}
if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) RemoveSnippetFile(id int64, name string) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec("DELETE FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
return err
}
return tx.Commit()
}
- Step 5: Run the store tests
Run: go test ./internal/store
Expected: PASS, including TestMigrateUpDown (the down file drops both tables) and TestSnippets.
- Step 6: Commit
git add internal/store/migrations/0053_snippets.up.sql internal/store/migrations/0053_snippets.down.sql internal/store/snippets.go internal/store/snippets_test.go
git commit -m "store: snippets and snippet_files (migration 0053)
Ref #195"
Task 2: Policy, config limit, and the snippet commands
Files:
- Create:
internal/policy/snippets.go - Modify:
internal/config/config.go:175-193(theLimitsstruct) and the defaults near line 217 - Create:
internal/control/snippet.go - Modify:
cmd/gitbay/main.go:74-77(after thewikigroup) - Modify:
e2e/readonly_test.go:72-73(fixtures) and:146-158(readArgs) - Test:
e2e/snippet_test.go
Interfaces:
- Consumes the store API from Task 1.
- Produces:
// internal/policy/snippets.go
func CanReadSnippet(user store.User, sn store.Snippet) bool
func CanWriteSnippet(user store.User, sn store.Snippet) bool
// internal/config/config.go
Limits.MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // default 1 << 20
// internal/control/snippet.go
type SnippetFileOut struct {
Name string `json:"name"`
Size int64 `json:"size"`
Content string `json:"content,omitempty"`
}
type SnippetOut struct {
ID string `json:"id"`
URL string `json:"url"`
Owner string `json:"owner"`
Description string `json:"description"`
Visibility string `json:"visibility"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Files []SnippetFileOut `json:"files"`
}
Commands registered: snippet create, snippet show, snippet list, snippet edit, snippet delete, snippet file set, snippet file get, snippet file remove.
- Step 1: Write the failing e2e test
Create e2e/snippet_test.go:
package e2e
import (
"encoding/json"
"regexp"
"strings"
"testing"
)
// Snippets over SSH: create from stdin, read back, list by visibility,
// edit files and metadata, and the not-found rule for private ones.
func TestSnippets(t *testing.T) {
inst := startInstance(t)
aliceKey := inst.newKey(t, "alice")
bobKey := inst.newKey(t, "bob")
inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
must := func(key, stdin string, args ...string) string {
t.Helper()
out, errOut, code := inst.ssh(t, key, stdin, args...)
if code != 0 {
t.Fatalf("%v: exit %d %s", args, code, errOut)
}
return out
}
fails := func(key, stdin string, want int, args ...string) string {
t.Helper()
_, errOut, code := inst.ssh(t, key, stdin, args...)
if code != want {
t.Fatalf("%v: exit %d, want %d: %s", args, code, want, errOut)
}
return errOut
}
idOf := func(out string) string {
t.Helper()
var env struct {
Data struct {
ID string `json:"id"`
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil || !regexp.MustCompile(`^[0-9a-f]{12}$`).MatchString(env.Data.ID) {
t.Fatalf("create output: %s", out)
}
if !strings.HasSuffix(env.Data.URL, "/alice/-/snippets/"+env.Data.ID) {
t.Fatalf("url: %s", env.Data.URL)
}
return env.Data.ID
}
// Create with the default visibility, read back byte for byte.
body := "line one\nline two\n"
unlisted := idOf(must(aliceKey, body, "snippet", "create", "build.log", "--description", "'a log'", "--json"))
if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != body {
t.Fatalf("file get: %q", got)
}
out := must(aliceKey, "", "snippet", "show", unlisted, "--json")
if !strings.Contains(out, `"visibility":"unlisted"`) || !strings.Contains(out, `"content":"line one\nline two\n"`) {
t.Fatalf("show: %s", out)
}
public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json"))
private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json"))
// Refusals on create: empty, not text, over the limit, bad name.
fails(aliceKey, "", 2, "snippet", "create", "x.txt")
fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin")
fails(aliceKey, strings.Repeat("x", 1<<20+1), 2, "snippet", "create", "big.txt")
fails(aliceKey, "x\n", 2, "snippet", "create", "../x")
fails(aliceKey, "x\n", 2, "snippet", "create", "x.txt", "--visibility", "secret")
// Visibility from the other side. Private is not-found, never denied.
fails(bobKey, "", 3, "snippet", "show", private)
fails(bobKey, "", 3, "snippet", "file", "get", private, "b.txt")
must(bobKey, "", "snippet", "show", unlisted)
out = must(bobKey, "", "snippet", "list", "alice", "--json")
if !strings.Contains(out, public) || strings.Contains(out, unlisted) || strings.Contains(out, private) {
t.Fatalf("bob's view of alice's list: %s", out)
}
out = must(aliceKey, "", "snippet", "list", "--json")
for _, id := range []string{public, unlisted, private} {
if !strings.Contains(out, id) {
t.Fatalf("alice's own list lacks %s: %s", id, out)
}
}
fails(bobKey, "", 3, "snippet", "list", "nobody")
// Paging: two pages of one, the second reached by cursor.
out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--json")
var page struct {
Data struct {
Items []struct{ ID string `json:"id"` } `json:"items"`
Next string `json:"next"`
} `json:"data"`
}
json.Unmarshal([]byte(out), &page)
if len(page.Data.Items) != 1 || page.Data.Items[0].ID != private || page.Data.Next == "" {
t.Fatalf("first page: %s", out)
}
out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--cursor", page.Data.Next, "--json")
if !strings.Contains(out, public) {
t.Fatalf("second page: %s", out)
}
// Files: set adds, set replaces, remove drops, the last one stays.
must(aliceKey, "notes\n", "snippet", "file", "set", unlisted, "notes.txt")
must(aliceKey, "changed\n", "snippet", "file", "set", unlisted, "build.log")
if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != "changed\n" {
t.Fatalf("after replace: %q", got)
}
must(aliceKey, "", "snippet", "file", "remove", unlisted, "notes.txt")
fails(aliceKey, "", 3, "snippet", "file", "remove", unlisted, "notes.txt")
if msg := fails(aliceKey, "", 2, "snippet", "file", "remove", unlisted, "build.log"); !strings.Contains(msg, "at least one file") {
t.Fatalf("last file removal: %s", msg)
}
// Only the owner writes: denied on a readable one, not-found on a private one.
fails(bobKey, "x\n", 4, "snippet", "file", "set", unlisted, "x.txt")
fails(bobKey, "", 4, "snippet", "edit", unlisted, "--description", "mine")
fails(bobKey, "", 4, "snippet", "delete", unlisted)
fails(bobKey, "", 3, "snippet", "delete", private)
// Edit moves visibility and the listing follows.
fails(aliceKey, "", 2, "snippet", "edit", unlisted)
must(aliceKey, "", "snippet", "edit", unlisted, "--visibility", "public", "--description", "shared")
out = must(bobKey, "", "snippet", "list", "alice", "--json")
if !strings.Contains(out, unlisted) || !strings.Contains(out, `"description":"shared"`) {
t.Fatalf("list after edit: %s", out)
}
// Delete, then gone; deleting the user takes the rest.
must(aliceKey, "", "snippet", "delete", unlisted)
fails(aliceKey, "", 3, "snippet", "show", unlisted)
inst.admin(t, "admin", "user", "delete", "alice", "--yes")
fails(bobKey, "", 3, "snippet", "show", public)
}
- Step 2: Run it to see it fail
Run: go test ./e2e -run 'TestSnippets$'
Expected: FAIL at the first must: unknown command "snippet" (or similar) with exit 2.
- Step 3: Policy
Create internal/policy/snippets.go:
package policy
import "gitbay.org/gitbay/internal/store"
// CanReadSnippet: anyone for public and unlisted, the owner and admins
// for private. Anonymous readers have user.ID 0.
func CanReadSnippet(user store.User, sn store.Snippet) bool {
if sn.Visibility != "private" {
return true
}
return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
}
// CanWriteSnippet: the owner and admins.
func CanWriteSnippet(user store.User, sn store.Snippet) bool {
return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
}
- Step 4: Config limit
In internal/config/config.go, add to Limits after MaxAssetBytes:
MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
and in the defaults block that sets MaxAssetBytes: 512 << 20, add:
MaxSnippetBytes: 1 << 20,
- Step 5: The commands
Create internal/control/snippet.go:
package control
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strconv"
"unicode/utf8"
"gitbay.org/gitbay/internal/policy"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
// A snippet keeps at most this many files; a paste is not a repository.
const maxSnippetFiles = 64
func init() {
register(Command{Path: []string{"snippet", "create"},
Summary: "create a snippet from one file on stdin",
Usage: "snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file",
ReadsStdin: true, Run: runSnippetCreate})
register(Command{Path: []string{"snippet", "show"},
Summary: "show a snippet's metadata and files",
Usage: "snippet show <id>", ReadOnly: true, Run: runSnippetShow})
register(Command{Path: []string{"snippet", "list"},
Summary: "list your snippets, or an owner's public ones",
Usage: "snippet list [<owner>] [--limit n] [--cursor c]", ReadOnly: true, Run: runSnippetList})
register(Command{Path: []string{"snippet", "edit"},
Summary: "change a snippet's description or visibility",
Usage: "snippet edit <id> [--description <d>] [--visibility public|unlisted|private]", Run: runSnippetEdit})
register(Command{Path: []string{"snippet", "delete"},
Summary: "delete a snippet and its files",
Usage: "snippet delete <id>", Run: runSnippetDelete})
register(Command{Path: []string{"snippet", "file", "set"},
Summary: "add a file to a snippet, or replace one, from stdin",
Usage: "snippet file set <id> <filename> < file",
ReadsStdin: true, Run: runSnippetFileSet})
register(Command{Path: []string{"snippet", "file", "get"},
Summary: "write a snippet file to stdout",
Usage: "snippet file get <id> <filename> > file", ReadOnly: true, Run: runSnippetFileGet})
register(Command{Path: []string{"snippet", "file", "remove"},
Summary: "remove a file from a snippet",
Usage: "snippet file remove <id> <filename>", Run: runSnippetFileRemove})
}
type SnippetFileOut struct {
Name string `json:"name"`
Size int64 `json:"size"`
Content string `json:"content,omitempty"`
}
type SnippetOut struct {
ID string `json:"id"`
URL string `json:"url"`
Owner string `json:"owner"`
Description string `json:"description"`
Visibility string `json:"visibility"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Files []SnippetFileOut `json:"files"`
}
func snippetURL(c *Ctx, sn store.Snippet) string {
return c.Cfg.Server.SiteURL + "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
}
func snippetOut(c *Ctx, sn store.Snippet) SnippetOut {
o := SnippetOut{ID: sn.PublicID, URL: snippetURL(c, sn), Owner: sn.OwnerName,
Description: sn.Description, Visibility: sn.Visibility,
CreatedAt: sn.CreatedAt, UpdatedAt: sn.UpdatedAt, Files: []SnippetFileOut{}}
for _, f := range sn.Files {
o.Files = append(o.Files, SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)})
}
return o
}
func validSnippetVisibility(v string) bool {
return v == "public" || v == "unlisted" || v == "private"
}
// snippetRef loads a snippet the caller may read; with write, one they
// may change. Unreadable and missing are the same not-found, so a
// private id cannot be confirmed by probing.
func snippetRef(c *Ctx, id string, write bool) (store.Snippet, int) {
sn, err := c.Store.SnippetByPublicID(id)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return sn, c.fail(protocol.ExitFailure, "%v", err)
}
if err != nil || !policy.CanReadSnippet(c.User, sn) {
return sn, c.fail(protocol.ExitNotFound, "no snippet %q", id)
}
if write && !policy.CanWriteSnippet(c.User, sn) {
return sn, c.fail(protocol.ExitDenied, "snippet %s belongs to %s", id, sn.OwnerName)
}
return sn, -1
}
// readSnippetBody reads one file from stdin under the limit, and insists
// on text: the page highlights it and the raw route serves text/plain.
func readSnippetBody(c *Ctx) ([]byte, int) {
limit := c.Cfg.Limits.MaxSnippetBytes
data, err := io.ReadAll(io.LimitReader(c.Stdin, limit+1))
if err != nil {
return nil, c.fail(protocol.ExitFailure, "reading stdin: %v", err)
}
if int64(len(data)) > limit {
return nil, c.fail(protocol.ExitUsage, "file exceeds max_snippet_bytes (%d)", limit)
}
if len(data) == 0 {
return nil, c.fail(protocol.ExitUsage, "empty file: pipe it on stdin")
}
if !utf8.Valid(data) {
return nil, c.fail(protocol.ExitUsage, "snippets hold text: the file is not valid UTF-8")
}
return data, -1
}
func checkSnippetFileName(c *Ctx, name string) int {
if !assetNamePat.MatchString(name) {
return c.fail(protocol.ExitUsage, "invalid file name %q: letters, digits, '._+-'; must not start with '.'", name)
}
return -1
}
func newSnippetID() string {
buf := make([]byte, 6)
rand.Read(buf)
return hex.EncodeToString(buf)
}
func runSnippetCreate(c *Ctx, args []string) int {
const usage = "usage: snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file"
f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
if err != nil {
return c.fail(protocol.ExitUsage, "%v", err)
}
name := f.pos(0)
if name == "" {
return c.fail(protocol.ExitUsage, usage)
}
if code := checkSnippetFileName(c, name); code >= 0 {
return code
}
visibility := f.Value("--visibility")
if visibility == "" {
visibility = "unlisted"
}
if !validSnippetVisibility(visibility) {
return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
}
data, code := readSnippetBody(c)
if code >= 0 {
return code
}
var pid string
for try := 0; ; try++ {
pid = newSnippetID()
_, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
if !errors.Is(err, store.ErrExists) || try == 4 {
break
}
}
if err != nil {
return c.failErr(err)
}
sn, err := c.Store.SnippetByPublicID(pid)
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
return c.emit(snippetOut(c, sn), func(w io.Writer) {
fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
})
}
func runSnippetShow(c *Ctx, args []string) int {
if len(args) != 1 {
return c.fail(protocol.ExitUsage, "usage: snippet show <id>")
}
sn, code := snippetRef(c, args[0], false)
if code >= 0 {
return code
}
files, err := c.Store.SnippetFiles(sn.ID)
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
sn.Files = files
return c.emit(snippetOut(c, sn), func(w io.Writer) {
fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
if sn.Description != "" {
fmt.Fprintf(w, "%s\n", sn.Description)
}
fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
for _, f := range files {
fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size)
}
})
}
func runSnippetList(c *Ctx, args []string) int {
rest, p, code := parsePageFlags(c, args, "snippet", true)
if code >= 0 {
return code
}
if len(rest) > 1 {
return c.fail(protocol.ExitUsage, "usage: snippet list [<owner>] [--limit n] [--cursor c]")
}
owner := c.User
if len(rest) == 1 {
u, err := c.Store.UserByUsername(rest[0])
if errors.Is(err, store.ErrNotFound) {
return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
}
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
owner = u
}
all := owner.ID == c.User.ID || c.User.IsAdmin
rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
items := make([]SnippetOut, 0, len(rows))
for _, sn := range rows {
items = append(items, snippetOut(c, sn))
}
return c.emitPage(p, items, next, func(w io.Writer) {
for _, sn := range rows {
names := ""
for i, f := range sn.Files {
if i > 0 {
names += ", "
}
names += f.Name
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
}
})
}
func runSnippetEdit(c *Ctx, args []string) int {
const usage = "usage: snippet edit <id> [--description <d>] [--visibility public|unlisted|private]"
f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
if err != nil {
return c.fail(protocol.ExitUsage, "%v", err)
}
if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
return c.fail(protocol.ExitUsage, usage)
}
sn, code := snippetRef(c, f.pos(0), true)
if code >= 0 {
return code
}
description, visibility := sn.Description, sn.Visibility
if f.Has("--description") {
description = f.Value("--description")
}
if f.Has("--visibility") {
visibility = f.Value("--visibility")
if !validSnippetVisibility(visibility) {
return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
}
}
if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
return c.failErr(err)
}
sn, err = c.Store.SnippetByPublicID(sn.PublicID)
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
return c.emit(snippetOut(c, sn), func(w io.Writer) {
fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
})
}
func runSnippetDelete(c *Ctx, args []string) int {
if len(args) != 1 {
return c.fail(protocol.ExitUsage, "usage: snippet delete <id>")
}
sn, code := snippetRef(c, args[0], true)
if code >= 0 {
return code
}
if err := c.Store.DeleteSnippet(sn.ID); err != nil {
return c.failErr(err)
}
return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
})
}
func runSnippetFileSet(c *Ctx, args []string) int {
if len(args) != 2 {
return c.fail(protocol.ExitUsage, "usage: snippet file set <id> <filename> < file")
}
sn, code := snippetRef(c, args[0], true)
if code >= 0 {
return code
}
name := args[1]
if code := checkSnippetFileName(c, name); code >= 0 {
return code
}
exists := false
for _, f := range sn.Files {
exists = exists || f.Name == name
}
if !exists && len(sn.Files) >= maxSnippetFiles {
return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
}
data, code := readSnippetBody(c)
if code >= 0 {
return code
}
if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
return c.failErr(err)
}
return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
})
}
func runSnippetFileGet(c *Ctx, args []string) int {
if len(args) != 2 {
return c.fail(protocol.ExitUsage, "usage: snippet file get <id> <filename> > file")
}
sn, code := snippetRef(c, args[0], false)
if code >= 0 {
return code
}
f, err := c.Store.SnippetFile(sn.ID, args[1])
if errors.Is(err, store.ErrNotFound) {
return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
}
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
if c.JSON {
return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
}
if _, err := c.Stdout.Write(f.Content); err != nil {
return protocol.ExitFailure
}
return protocol.ExitOK
}
func runSnippetFileRemove(c *Ctx, args []string) int {
if len(args) != 2 {
return c.fail(protocol.ExitUsage, "usage: snippet file remove <id> <filename>")
}
sn, code := snippetRef(c, args[0], true)
if code >= 0 {
return code
}
if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
}
err := c.Store.RemoveSnippetFile(sn.ID, args[1])
if errors.Is(err, store.ErrNotFound) {
return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
}
if err != nil {
return c.failErr(err)
}
return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
})
}
One note for the implementer: c.emit(..., nil) in runSnippetFileGet is only reached under c.JSON, where emit never calls the plain function; keep the if c.JSON guard.
- Step 6: CLI rows
In cmd/gitbay/main.go, directly after the group("wiki", ...) entry (around line 77), add:
group("snippet", "shared text files, outside any repository",
pass("create", "create from one file on stdin: <filename> [--description d] [--visibility public|unlisted|private] < file",
passOpts{server: []string{"snippet", "create"}, alwaysStdin: true, stdinWhat: "the file's text"}),
pass("show", "metadata and files: <id>", passOpts{server: []string{"snippet", "show"}}),
pass("list", "your snippets, or an owner's public ones: [<owner>] [--limit n] [--cursor c]",
passOpts{server: []string{"snippet", "list"}}),
pass("edit", "change description or visibility: <id> [--description d] [--visibility v]",
passOpts{server: []string{"snippet", "edit"}}),
pass("delete", "delete a snippet: <id>", passOpts{server: []string{"snippet", "delete"}}),
group("file", "the files in a snippet",
pass("set", "add or replace a file from stdin: <id> <filename> < file",
passOpts{server: []string{"snippet", "file", "set"}, alwaysStdin: true, stdinWhat: "the file's text"}),
pass("get", "print a file: <id> <filename> > file", passOpts{server: []string{"snippet", "file", "get"}}),
pass("remove", "remove a file: <id> <filename>", passOpts{server: []string{"snippet", "file", "remove"}}),
),
),
- Step 7: Read-only coverage entries
In e2e/readonly_test.go, after the line must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt") add:
snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]
Add "regexp" to the file's imports if it is not there. In the readArgs map add, beside the release rows:
"snippet show": {snippetID},
"snippet list": {},
"snippet file get": {snippetID, "a.txt"},
- Step 8: Build, vet, run the tests
Run: go build ./... && go vet ./... && go test ./internal/control ./internal/policy ./internal/config && go test ./e2e -run 'TestSnippets$|TestCLI$|TestReadOnlyCommandsWriteNothing$'
Expected: all PASS. TestCLI proves every snippet command has a CLI row with a stdinWhat; TestReadOnlyCommandsWriteNothing proves the three reads write nothing.
If TestSnippets fails on the paged next cursor, check that parsePageFlags was called with numeric=true and that trimPage keys on sn.ID, not sn.PublicID.
- Step 9: Commit
git add internal/policy/snippets.go internal/config/config.go internal/control/snippet.go cmd/gitbay/main.go e2e/readonly_test.go e2e/snippet_test.go
git commit -m "control: snippet commands
create, show, list, edit, delete, and file set|get|remove. Content is
UTF-8 under limits.max_snippet_bytes per file, 64 files per snippet.
Private snippets are not-found to everyone but the owner and admins.
Ref #195"
Task 3: Web read pages and the owner-page link
Files:
- Create:
internal/httpd/snippets.go - Create:
internal/web/templates/snippets.html - Create:
internal/web/templates/snippet.html - Modify:
internal/httpd/routes.go:74-75(beside the/{owner}/-/labelsroutes) - Modify:
internal/control/profile.go:165-185(ProfileOut) and theprofile showbody near line 296 - Modify:
internal/httpd/web.go:436-466(theowner.htmlpage struct and its literal) - Modify:
internal/web/templates/owner.html:21-25 - Test:
e2e/snippetweb_test.go
Interfaces:
-
Consumes
store.SnippetByPublicID,store.SnippetFiles,store.SnippetFile,store.ListSnippets,store.CountSnippets,policy.CanReadSnippet,policy.CanWriteSnippet,highlight(path string, data []byte) template.HTMLininternal/httpd/web.go. -
Produces
ProfileOut.Snippets int(json:"snippets"): the owner's snippets the caller may list (public, or all for the owner and admins). Produces the handlerssnippetsPage,snippetPage,snippetRawand the helpersnippetScope, which Task 4's write handlers reuse. -
Step 1: Write the failing e2e test (read half)
Create e2e/snippetweb_test.go:
package e2e
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
)
func snippetIDFrom(t *testing.T, out string) string {
t.Helper()
var env struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" {
t.Fatalf("snippet create: %s", out)
}
return env.Data.ID
}
// Snippet pages: the owner's list, one snippet with highlighted files, the
// raw route, the owner-page link, and 404 for what the viewer may not see.
func TestSnippetsWeb(t *testing.T) {
inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
aliceKey := inst.newKey(t, "alice")
bobKey := inst.newKey(t, "bob")
inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
must := func(key, stdin string, args ...string) string {
t.Helper()
out, errOut, code := inst.ssh(t, key, stdin, args...)
if code != 0 {
t.Fatalf("%v: exit %d %s", args, code, errOut)
}
return out
}
public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json"))
unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json"))
private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json"))
// Anonymous: the public list, the unlisted page by URL, 404 for private.
status, body := inst.get(t, "/alice/-/snippets")
if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) {
t.Fatalf("anonymous list: %d\n%s", status, body)
}
status, body = inst.get(t, "/alice/-/snippets/"+public)
if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") {
t.Fatalf("public page: %d\n%s", status, body)
}
if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 {
t.Fatalf("unlisted page: %d", status)
}
if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 {
t.Fatalf("private page for anonymous: %d", status)
}
if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 {
t.Fatalf("id under the wrong owner: %d", status)
}
if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 {
t.Fatalf("list for a missing owner: %d", status)
}
// Raw is text/plain with nosniff, whatever the extension.
resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
}
if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
t.Fatalf("raw for a missing file: %d", status)
}
// The owner sees everything with visibility marks; the owner page links.
alice := inst.login(t, aliceKey)
status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets")
if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") {
t.Fatalf("owner list: %d\n%s", status, body)
}
if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") {
t.Fatalf("owner's private page: %d", status)
}
if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) {
t.Fatalf("owner page lacks the snippets link: %d", status)
}
// bob has no public snippets and is not the viewer: no link.
if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) {
t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
}
_ = url.Values{}
_ = bobKey
}
The last two lines keep the imports and bobKey used until Task 4 extends the test; Task 4 removes them.
- Step 2: Run it to see it fail
Run: go test ./e2e -run 'TestSnippetsWeb$'
Expected: FAIL at "anonymous list", status 404.
- Step 3: Profile count
In internal/control/profile.go, add to ProfileOut after Repos:
// Snippets counts the owner's snippets the caller may list: public
// ones, or all of them for the owner and admins. Orgs own none.
Snippets int `json:"snippets"`
In the profile show body, after the loop that fills d.Repos and before the activity counts, add:
if kind == "user" {
all := id == c.User.ID || c.User.IsAdmin
if d.Snippets, err = c.Store.CountSnippets(id, all); err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
}
(kind and id are the variables the surrounding code already uses for the owner's kind and row id; read the function and use its names.)
- Step 4: Routes
In internal/httpd/routes.go, after the /{owner}/-/milestones route add:
Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},
These are read routes, registered in every web mode; the literal - and snippets segments keep them from overlapping any /{owner}/{repo}/... pattern.
- Step 5: Handlers
Create internal/httpd/snippets.go:
package httpd
import (
"bytes"
"html/template"
"net/http"
"gitbay.org/gitbay/internal/policy"
"gitbay.org/gitbay/internal/store"
)
// snippetScope resolves the owner and id in the URL for the viewer. A
// missing owner, an id under another owner, and a private snippet the
// viewer may not read are all the same 404.
func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) {
viewer := s.viewer(r)
sn, err := s.st.SnippetByPublicID(r.PathValue("id"))
if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) {
s.notFound(w, r)
return sn, viewer, false
}
return sn, viewer, true
}
type snippetRow struct {
store.Snippet
Names string
}
func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
viewer := s.viewer(r)
owner, err := s.st.UserByUsername(r.PathValue("owner"))
if err != nil {
s.notFound(w, r)
return
}
self := viewer.ID != 0 && viewer.ID == owner.ID
all := self || viewer.IsAdmin
list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rows := make([]snippetRow, 0, len(list))
for _, sn := range list {
var names bytes.Buffer
for i, f := range sn.Files {
if i > 0 {
names.WriteString(", ")
}
names.WriteString(f.Name)
}
rows = append(rows, snippetRow{sn, names.String()})
}
s.render(w, "snippets.html", struct {
basePage
Owner string
Self bool
All bool
Snippets []snippetRow
Notice string
}{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)})
}
type snippetFileView struct {
Name string
Size int64
Lines int
Content string
HTML template.HTML
}
func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) {
sn, viewer, ok := s.snippetScope(w, r)
if !ok {
return
}
files, err := s.st.SnippetFiles(sn.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
views := make([]snippetFileView, 0, len(files))
for _, f := range files {
lines := bytes.Count(f.Content, []byte("\n"))
if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' {
lines++
}
views = append(views, snippetFileView{f.Name, f.Size, lines, string(f.Content), highlight(f.Name, f.Content)})
}
s.render(w, "snippet.html", struct {
basePage
Owner string
Snippet store.Snippet
Files []snippetFileView
CanWrite bool
Notice string
}{s.baseFor(viewer), sn.OwnerName, sn, views, policy.CanWriteSnippet(viewer, sn), s.takeFlash(w, r)})
}
// snippetRaw serves one file as text, inert on the forge's origin.
func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
sn, _, ok := s.snippetScope(w, r)
if !ok {
return
}
f, err := s.st.SnippetFile(sn.ID, r.PathValue("name"))
if err != nil {
s.notFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Write(f.Content)
}
s.viewer reads the session cookie; in view_only mode there is never one, so the viewer is anonymous there without a mode check.
- Step 6: Templates
Create internal/web/templates/snippets.html:
{{define "title"}}snippets · {{.Owner}}{{end}}
{{define "content"}}
<h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1>
{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
{{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create <file> < file</code></p>{{end}}
{{if .Snippets}}<div class="tablewrap"><table class="keys">
<tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .All}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr>
{{range .Snippets}}<tr>
<td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
<td><span class="mono">{{.Names}}</span></td>
{{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
<td>{{.UpdatedAt}}</td>
</tr>
{{end}}</table></div>
{{else}}<p class="none">No snippets yet.</p>{{end}}
{{end}}
Create internal/web/templates/snippet.html (the write forms come in Task 4; leave the {{if .CanWrite}} block out for now):
{{define "title"}}{{if .Snippet.Description}}{{.Snippet.Description}}{{else}}{{.Snippet.PublicID}}{{end}} · {{.Owner}}{{end}}
{{define "content"}}
<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1>
{{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}}
<p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{.Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p>
{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
{{range .Files}}
<section class="snippetfile" id="file-{{.Name}}">
<div class="pathbar">
<span class="crumbs"><strong>{{.Name}}</strong></span>
<span class="spacer"></span>
<span class="actions"><a href="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/raw/{{.Name}}">raw</a></span>
</div>
<p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p>
<div class="code">{{.HTML}}</div>
</section>
{{end}}
{{end}}
- Step 7: Owner page link
In internal/httpd/web.go, in the owner.html page struct add Snippets int after Self bool, and in the literal pass d.Snippets after the Self expression (the d.Kind == "user" && ... line). In internal/web/templates/owner.html, after the </ul> that closes the repository list, add:
{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
- Step 8: Build and run the tests
Run: go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'
Expected: PASS. internal/httpd's template tests check the new templates for unlabeled inputs (none yet) and route/reserved-name agreement (nothing new at the top level).
- Step 9: Commit
git add internal/httpd/snippets.go internal/httpd/routes.go internal/httpd/web.go internal/control/profile.go internal/web/templates/snippets.html internal/web/templates/snippet.html internal/web/templates/owner.html e2e/snippetweb_test.go
git commit -m "web: snippet pages
The owner's list, one snippet with highlighted files, and a raw route
under /{owner}/-/snippets. The owner page links when there is
something to list.
Ref #195"
Task 4: Web writes
Files:
- Modify:
internal/httpd/snippets.go(append the write handlers) - Modify:
internal/httpd/routes.go(the account-mode block, beside the/bookmarksroutes) - Modify:
internal/web/templates/snippet.html(the{{if .CanWrite}}forms) - Create:
internal/web/templates/snippetnew.html - Test:
e2e/snippetweb_test.go(extend)
Interfaces:
-
Consumes
snippetScope,control.SnippetOut,s.dispatchIntoStdin,s.runControlCode,s.runControlStdinCode,s.done,s.setFlash,statusForExit. -
Produces the five POST handlers and the GET form named in the constraints.
-
Step 1: Extend the e2e test
In e2e/snippetweb_test.go, replace the two placeholder lines (_ = url.Values{} and _ = bobKey) with:
// The create form makes a snippet through snippet create.
status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
"name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
t.Fatalf("create form: %d\n%s", status, body)
}
var listed struct {
Data []struct {
ID string `json:"id"`
Description string `json:"description"`
} `json:"data"`
}
json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
created := ""
for _, sn := range listed.Data {
if sn.Description == "from the browser" {
created = sn.ID
}
}
if created == "" {
t.Fatalf("created from the web, not listed: %+v", listed.Data)
}
if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
t.Fatalf("new form under another owner: %d", status)
}
// The file form replaces a file and adds one; remove drops it.
page := inst.base() + "/alice/-/snippets/" + created
if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
t.Fatal("file replace failed")
}
if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
t.Fatalf("after web replace: %q", got)
}
if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
t.Fatal("file add failed")
}
if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
t.Fatal("file remove failed")
}
if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
t.Fatalf("b.txt after web remove: exit %d", code)
}
// A refusal comes back on the page as a message, not a bare error.
_, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
t.Fatalf("last-file refusal on the page:\n%s", body)
}
// Edit changes visibility; delete removes.
if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
t.Fatal("edit failed")
}
if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
t.Fatalf("private after web edit, anonymous: %d", status)
}
// bob cannot write alice's snippet from the browser either.
bob := inst.login(t, bobKey)
if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
t.Fatalf("bob editing alice's snippet: %d", status)
}
if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
t.Fatal("delete failed")
}
if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
t.Fatalf("after web delete: exit %d", code)
}
browserPost follows redirects, so a successful form lands on the page it redirects to with status 200 and the page's body. The id of the snippet the form made comes from snippet list --json, matched by its description.
- Step 2: Run it to see it fail
Run: go test ./e2e -run 'TestSnippetsWeb$'
Expected: FAIL at "create form" with 404 or 405.
- Step 3: Routes
In internal/httpd/routes.go, inside the web.mode == "accounts" block, after the /bookmarks GET route add:
Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/edit", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetEditSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/delete", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetDeleteSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetFileSubmit))},
Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file/remove", Mutating: true,
Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
GET /{owner}/-/snippets/new and GET /{owner}/-/snippets/{id} both match /x/-/snippets/new; the literal segment is more specific, so the mux picks the form.
- Step 4: Handlers
Append to internal/httpd/snippets.go (add "strings", "gitbay.org/gitbay/internal/control" and "gitbay.org/gitbay/internal/protocol" to its imports):
// snippetNewForm is the owner's own page only: the URL names the owner
// and a snippet cannot be created for someone else.
func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
if r.PathValue("owner") != u.Username {
s.notFound(w, r)
return
}
s.render(w, "snippetnew.html", struct {
basePage
Owner string
}{s.baseFor(u), u.Username})
}
func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
if r.PathValue("owner") != u.Username {
s.notFound(w, r)
return
}
argv := []string{"snippet", "create", strings.TrimSpace(r.FormValue("name")),
"--description", strings.TrimSpace(r.FormValue("description")),
"--visibility", r.FormValue("visibility")}
var out control.SnippetOut
code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("content"), &out)
if code != protocol.ExitOK {
http.Error(w, msg, statusForExit(code))
return
}
http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
}
// snippetAction runs a write on the snippet in the URL and returns to
// its page with the message, or to the list after a delete. A snippet
// the viewer may not read is the 404 page, as on every read.
func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
sn, _, ok := s.snippetScope(w, r)
if !ok {
return
}
if dest == "" {
dest = "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
}
back := func(w http.ResponseWriter, r *http.Request, msg string) {
s.setFlash(w, msg)
http.Redirect(w, r, dest, http.StatusSeeOther)
}
var msg string
var code int
if stdin == "" {
_, msg, code = s.runControlCode(u, argv)
} else {
msg, code = s.runControlStdinCode(u, argv, stdin)
}
if code == protocol.ExitDenied {
http.Error(w, msg, http.StatusForbidden)
return
}
s.done(w, r, code, msg, back)
}
func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
"--description", strings.TrimSpace(r.FormValue("description")),
"--visibility", r.FormValue("visibility")}, "", "")
}
func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
"/"+r.PathValue("owner")+"/-/snippets")
}
// An empty textarea reaches the command as empty stdin, which it refuses;
// the message lands on the page like any other.
func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
r.FormValue("content"), "")
}
func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
}
The denied branch answers 403 rather than a redirect because the viewer can read the page but not change it; the e2e test asserts it. Browsers send \r\n from a textarea; the command stores what it receives, which is what the raw route serves back. Do not normalise.
- Step 5: Templates
Create internal/web/templates/snippetnew.html:
{{define "title"}}new snippet · {{.Owner}}{{end}}
{{define "content"}}
<h1>New snippet</h1>
<form method="post" action="/{{.Owner}}/-/snippets/new" class="commentform">
<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
<p><input type="text" name="description" aria-label="Description" placeholder="description"></p>
<p><select name="visibility" aria-label="Visibility"><option value="unlisted">unlisted</option><option value="public">public</option><option value="private">private</option></select></p>
<p><textarea name="content" aria-label="Content" rows="16" required></textarea></p>
<p><button type="submit">Create snippet</button></p>
</form>
{{end}}
In internal/web/templates/snippet.html, inside the {{range .Files}} section after <div class="code">{{.HTML}}</div>, add:
{{if $.CanWrite}}<details class="editbox"><summary>edit {{.Name}}</summary>
<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform">
<input type="hidden" name="name" value="{{.Name}}">
<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p>
<p><button type="submit">Save</button></p>
</form>
<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
<input type="hidden" name="name" value="{{.Name}}">
<p><button type="submit">Remove {{.Name}}</button></p>
</form>
</details>{{end}}
and after the {{end}} that closes the range, before the final {{end}}:
{{if .CanWrite}}
<details class="editbox"><summary>add a file</summary>
<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform">
<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p>
<p><button type="submit">Add file</button></p>
</form></details>
<details class="editbox"><summary>settings</summary>
<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit">
<p><input type="text" name="description" aria-label="Description" value="{{.Snippet.Description}}" placeholder="description"></p>
<p><select name="visibility" aria-label="Visibility">
<option value="public"{{if eq .Snippet.Visibility "public"}} selected{{end}}>public</option>
<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option>
<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option>
</select></p>
<p><button type="submit">Save</button></p>
</form>
<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
<p><button type="submit">Delete snippet</button></p>
</form>
</details>
{{end}}
If .editbox or .commentform render poorly beside .code, add a .snippetfile { margin-bottom: 1.5rem } rule to internal/web/static/style.css; nothing more.
- Step 6: Build and run the tests
Run: go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'
Expected: PASS. internal/httpd carries the structural checks: TestMutatingRoutesRequireCheckOrigin (every Mutating route is wrapped in checkOrigin), TestViewOnlyHasNoMutatingRoutes (the POST routes sit inside the accounts block), and the input-label test on snippetnew.html and the new forms.
- Step 7: Commit
git add internal/httpd/snippets.go internal/httpd/routes.go internal/web/templates/snippet.html internal/web/templates/snippetnew.html e2e/snippetweb_test.go
git commit -m "web: create, edit and delete snippets
Every form dispatches the snippet command the CLI runs.
Ref #195"
Task 5: Documentation and changelog
Files:
-
Modify:
.gitbay/wiki/Users.org(a* Snippetssection after* Pages, before* Browser sessions) -
Modify:
.gitbay/wiki/Parity.org(rows afterrelease asset remove) -
Modify:
.gitbay/wiki/Admin.org:116(the[limits]list) -
Modify:
CHANGELOG.org(a new top entry) -
Step 1: Users.org
Insert before * Browser sessions:
* Snippets
A snippet is one or more named text files you own outside any
repository, for a log or a fragment shared by URL. Create one from a
file on stdin; the reply is the id and the URL:
#+begin_src sh
gitbay snippet create build.log --description "failing build" < build.log
gitbay snippet file set <id> notes.txt < notes.txt # add or replace a file
gitbay snippet file get <id> build.log > build.log
gitbay snippet edit <id> --visibility public
gitbay snippet list # yours
gitbay snippet list <owner> # their public ones
gitbay snippet delete <id>
#+end_src
Visibility is =public= (listed on your page), =unlisted= (anyone with
the URL, listed nowhere; the default) or =private= (you alone; not
found to everyone else). Files are text, valid UTF-8, each under the
instance's =max_snippet_bytes=, at most 64 per snippet. A snippet keeps
at least one file. There is no history: setting a file replaces it.
On the web, =/<you>/-/snippets= lists yours, each snippet page renders
its files with a raw link per file, and the same page creates, edits
and deletes through the commands above.
- Step 2: Parity.org
After the release asset remove row add:
| snippet create, edit, delete | yes | yes | no |
| snippet show, list | yes | yes | no |
| snippet file set, get, remove | yes | yes | no |
Match the table's column alignment by hand; org tables tolerate ragged cells but the page is read raw too.
- Step 3: Admin.org
After the max_asset_bytes line in ** [limits] add:
- =max_snippet_bytes= (1MB) — cap per snippet file.
- Step 4: CHANGELOG.org
Before * v1.19.0 — 2026-09-11 add:
* v1.20.0 — unreleased
Snippets (#195): named text files a user owns outside any repository,
shared by URL and edited in place.
- Migration 0053: =snippets= and =snippet_files=.
- =snippet create|show|list|edit|delete= and =snippet file
set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
(1MB), at most 64 per snippet; a snippet keeps at least one.
Visibility =public=, =unlisted= (default) or =private=; a private
snippet is not found to everyone but its owner and admins.
- Web: =/<owner>/-/snippets= lists, each snippet page renders its
files with a raw route per file, and the owner creates, edits and
deletes from the page through the same commands. The owner page
links to the list.
- Step 5: Commit
git add .gitbay/wiki/Users.org .gitbay/wiki/Parity.org .gitbay/wiki/Admin.org CHANGELOG.org
git commit -m "wiki, CHANGELOG: snippets
Closes #195"
Task 6: Merge request
- Step 1: Push and open the MR
git push -u origin snippets
gitbay mr create --source snippets --target main --title "Snippets (#195)" --file - <<'EOF'
Named text files a user owns outside any repository, shared by URL and
edited in place. Spec: docs/specs/2026-09-11-snippets-design.md.
Migration 0053. Commands snippet create|show|list|edit|delete and
snippet file set|get|remove; web under /{owner}/-/snippets with forms
dispatching the same commands. New limit max_snippet_bytes (1MB).
Closes #195
EOF
- Step 2: Wait for CI, then merge
Check gitbay build list --json until the build for the branch head succeeds; read gitbay build log <n> on failure and fix on the branch. Then:
gitbay mr merge <n> --strategy ff
git push origin --delete snippets
and remove the worktree and branch locally after the merge lands.