▍ humdrum codex / custard v0.3.0
license AGPL-3.0
38.4 KB raw

jj Bookmark-Mode Releases 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: Let a repo publish Homebrew releases and show version labels from a git bookmark (jj branch like release/v1.2.3) instead of a git tag, so pure-jj repos work — without touching the existing tag path.

Architecture: A per-repo .custard.yaml brew.source flag selects one release mode: tag (default, current behavior) or bookmark. In bookmark mode the webhook triggers off a refs/heads/<prefix>vX.Y.Z push, git archive runs against that branch, the downgrade guard compares against the highest release bookmark, and the version pill / refs page read the bookmark's tip commit subject as its label. Modes never mix; switching is an explicit config flip. internal/gitread stays go-git-only and config-agnostic (the prefix is passed in); mode is decided in the release/server layers.

Tech Stack: Go 1.26, go-git v5, gopkg.in/yaml.v3, a-h/templ, stdlib net/http, git CLI (already used by git archive/tap push).

Global Constraints


File Structure


Task 1: Release config — source flag, prefix, bookmark version parser

Files:

Interfaces:

Create internal/release/release_test.go:

package release

import "testing"

func TestReleaseSourceAndPrefix(t *testing.T) {
	var c RepoConfig
	if got := c.ReleaseSource(); got != SourceTag {
		t.Errorf("default ReleaseSource = %q, want %q", got, SourceTag)
	}
	if got := c.Prefix(); got != "release/" {
		t.Errorf("default Prefix = %q, want release/", got)
	}
	c.Brew.Source = "bookmark"
	c.Brew.BookmarkPrefix = "rel/"
	if got := c.ReleaseSource(); got != SourceBookmark {
		t.Errorf("ReleaseSource = %q, want %q", got, SourceBookmark)
	}
	if got := c.Prefix(); got != "rel/" {
		t.Errorf("Prefix = %q, want rel/", got)
	}
}

func TestBookmarkVersion(t *testing.T) {
	cases := []struct {
		short, prefix, want string
		ok                  bool
	}{
		{"release/v1.2.3", "release/", "v1.2.3", true},
		{"rel/v0.1.0", "rel/", "v0.1.0", true},
		{"release/v1.2.3", "rel/", "", false},     // wrong prefix
		{"release/main", "release/", "", false},   // not semver
		{"main", "release/", "", false},           // no prefix
		{"release/v1.2", "release/", "", false},   // not 3-part
	}
	for _, c := range cases {
		got, ok := BookmarkVersion(c.short, c.prefix)
		if ok != c.ok || got != c.want {
			t.Errorf("BookmarkVersion(%q,%q) = %q,%v; want %q,%v", c.short, c.prefix, got, ok, c.want, c.ok)
		}
	}
}

Run: go test ./internal/release/ -run 'TestReleaseSourceAndPrefix|TestBookmarkVersion' -v Expected: FAIL — undefined: SourceTag, c.ReleaseSource undefined, undefined: BookmarkVersion.

In internal/release/release.go, extend the Brew struct (lines 27-30):

	Brew struct {
		Enabled        bool   `yaml:"enabled"`
		Package        string `yaml:"package"`         // go build path, default "."
		Source         string `yaml:"source"`          // "tag" (default) | "bookmark"
		BookmarkPrefix string `yaml:"bookmark_prefix"`  // bookmark mode; default "release/"
	} `yaml:"brew"`

Add below IsReleaseTag (after line 72):

// Release source modes. A repo uses exactly one; switching is an explicit edit.
const (
	SourceTag      = "tag"      // releases come from vX.Y.Z git tags (default)
	SourceBookmark = "bookmark" // releases come from <prefix>vX.Y.Z bookmarks (jj)
)

// ReleaseSource returns the normalized brew source ("tag" or "bookmark").
func (c RepoConfig) ReleaseSource() string {
	if c.Brew.Source == SourceBookmark {
		return SourceBookmark
	}
	return SourceTag
}

// Prefix returns the normalized bookmark prefix (default "release/").
func (c RepoConfig) Prefix() string {
	if c.Brew.BookmarkPrefix == "" {
		return "release/"
	}
	return c.Brew.BookmarkPrefix
}

// BookmarkVersion extracts the vX.Y.Z version from a release bookmark's short
// name under prefix. ok=false unless it matches prefix + a semver release tag.
func BookmarkVersion(short, prefix string) (string, bool) {
	rest, ok := strings.CutPrefix(short, prefix)
	if !ok || !IsReleaseTag(rest) {
		return "", false
	}
	return rest, true
}

(strings is already imported.)

Run: go test ./internal/release/ -run 'TestReleaseSourceAndPrefix|TestBookmarkVersion' -v Expected: PASS.

git add internal/release/release.go internal/release/release_test.go
git commit -m "feat(release): add brew.source flag + bookmark version parsing"

Task 2: gitread — release bookmark listing + downgrade guard

Files:

Interfaces:

Create internal/gitread/gitread_test.go:

package gitread

import (
	"os/exec"
	"path/filepath"
	"testing"
)

// newBareWithReleases builds a bare repo named "<root>/proj.git" containing one
// commit plus the given release branches (each pointing at its own commit whose
// subject is "ship <branch>"). Returns the Store rooted at the temp dir.
func newBareWithReleases(t *testing.T, branches ...string) *Store {
	t.Helper()
	root := t.TempDir()
	work := filepath.Join(root, "work")
	run := func(dir string, args ...string) {
		t.Helper()
		c := exec.Command("git", args...)
		c.Dir = dir
		c.Env = append(c.Environ(),
			"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_AUTHOR_DATE=2026-01-01T00:00:00",
			"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", "GIT_COMMITTER_DATE=2026-01-01T00:00:00")
		if out, err := c.CombinedOutput(); err != nil {
			t.Fatalf("git %v: %v: %s", args, err, out)
		}
	}
	run(root, "init", "-q", "-b", "main", "work")
	run(work, "commit", "-q", "--allow-empty", "-m", "init")
	for _, b := range branches {
		run(work, "checkout", "-q", "-b", b)
		run(work, "commit", "-q", "--allow-empty", "-m", "ship "+b)
		run(work, "checkout", "-q", "main")
	}
	run(work, "clone", "-q", "--bare", work, filepath.Join(root, "proj.git"))
	return New(root)
}

func TestReleaseBookmarks(t *testing.T) {
	s := newBareWithReleases(t, "release/v1.0.0", "release/v1.2.0", "release/v0.9.0", "feature/x")
	got, err := s.ReleaseBookmarks("proj", "release/")
	if err != nil {
		t.Fatalf("ReleaseBookmarks: %v", err)
	}
	want := []string{"v1.2.0", "v1.0.0", "v0.9.0"} // semver-descending, feature/x excluded
	if len(got) != len(want) {
		t.Fatalf("got %d refs %v, want %v", len(got), got, want)
	}
	for i, w := range want {
		if got[i].Name != w {
			t.Errorf("ReleaseBookmarks[%d].Name = %q, want %q", i, got[i].Name, w)
		}
	}
	if got[0].Message != "ship release/v1.2.0" {
		t.Errorf("Message = %q, want tip subject", got[0].Message)
	}
}

func TestLatestReleaseAndGuard(t *testing.T) {
	s := newBareWithReleases(t, "release/v1.0.0", "release/v1.2.0")
	latest, ok := s.LatestRelease("proj", "release/")
	if !ok || latest.Name != "v1.2.0" {
		t.Fatalf("LatestRelease = %q,%v; want v1.2.0,true", latest.Name, ok)
	}
	if !s.IsHighestRelease("proj", "release/", "v1.2.0") {
		t.Error("v1.2.0 should be highest (tie)")
	}
	if !s.IsHighestRelease("proj", "release/", "v2.0.0") {
		t.Error("v2.0.0 should be highest")
	}
	if s.IsHighestRelease("proj", "release/", "v1.1.0") {
		t.Error("v1.1.0 must NOT be highest (downgrade guard)")
	}

	empty := newBareWithReleases(t)
	if !empty.IsHighestRelease("proj", "release/", "v1.0.0") {
		t.Error("first release should be highest when none exist")
	}
}

Run: go test ./internal/gitread/ -run 'TestReleaseBookmarks|TestLatestReleaseAndGuard' -v Expected: FAIL — s.ReleaseBookmarks undefined, etc.

In internal/gitread/gitread.go, add after IsHighestVersion (line 229):

// ReleaseBookmarks returns the repo's release bookmarks: branches under prefix
// whose remaining name is a semver, as version-named Refs (Name = vX.Y.Z, Hash =
// tip commit, Message = tip commit subject), sorted semver-descending.
func (s *Store) ReleaseBookmarks(name, prefix string) ([]Ref, error) {
	repo, err := s.open(name)
	if err != nil {
		return nil, err
	}
	bs, err := repo.Branches()
	if err != nil {
		return nil, err
	}
	var out []Ref
	_ = bs.ForEach(func(r *plumbing.Reference) error {
		short := r.Name().Short()
		rest, ok := strings.CutPrefix(short, prefix)
		if !ok {
			return nil
		}
		if _, ok := parseSemver(rest); !ok {
			return nil
		}
		ref := Ref{Name: rest, Hash: r.Hash().String()}
		if c, err := repo.CommitObject(r.Hash()); err == nil {
			ref.Message = firstLine(c.Message)
		}
		out = append(out, ref)
		return nil
	})
	sort.Slice(out, func(i, j int) bool {
		vi, _ := parseSemver(out[i].Name)
		vj, _ := parseSemver(out[j].Name)
		return semverLess(vj, vi) // descending
	})
	return out, nil
}

// LatestRelease returns the highest-semver release bookmark, ok=false if none.
func (s *Store) LatestRelease(name, prefix string) (Ref, bool) {
	bm, err := s.ReleaseBookmarks(name, prefix)
	if err != nil || len(bm) == 0 {
		return Ref{}, false
	}
	return bm[0], true
}

// IsHighestRelease reports whether version is >= the highest release bookmark
// (ties allowed). Guards bookmark-mode publishes against out-of-order pushes.
// A non-semver version is never highest.
func (s *Store) IsHighestRelease(name, prefix, version string) bool {
	v, ok := parseSemver(version)
	if !ok {
		return false
	}
	latest, have := s.LatestRelease(name, prefix)
	if !have {
		return true
	}
	lv, _ := parseSemver(latest.Name)
	return !semverLess(v, lv)
}

(plumbing, sort, strings already imported.)

Run: go test ./internal/gitread/ -run 'TestReleaseBookmarks|TestLatestReleaseAndGuard' -v Expected: PASS.

git add internal/gitread/gitread.go internal/gitread/gitread_test.go
git commit -m "feat(gitread): list release bookmarks + bookmark downgrade guard"

Task 3: Publish — archive an arbitrary ref (branch or tag)

Files:

Interfaces:

Append to internal/release/release_test.go. Do not repeat the package release clause — merge these imports into the file's existing single import block (add os, os/exec, path/filepath, strings, custard/internal/gitread; testing is already there), then append the functions below:

func gitRun(t *testing.T, dir string, args ...string) {
	t.Helper()
	c := exec.Command("git", args...)
	c.Dir = dir
	c.Env = append(c.Environ(),
		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_AUTHOR_DATE=2026-01-01T00:00:00",
		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", "GIT_COMMITTER_DATE=2026-01-01T00:00:00")
	if out, err := c.CombinedOutput(); err != nil {
		t.Fatalf("git %v: %v: %s", args, err, out)
	}
}

// TestPublishArchivesBranchRef verifies bookmark mode: archive the release
// BRANCH while naming the tarball/formula by the VERSION.
func TestPublishArchivesBranchRef(t *testing.T) {
	root := t.TempDir()
	work := filepath.Join(root, "work")
	gitRun(t, root, "init", "-q", "-b", "main", "work")
	if err := os.WriteFile(filepath.Join(work, "go.mod"), []byte("module proj\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	gitRun(t, work, "add", ".")
	gitRun(t, work, "commit", "-q", "-m", "init")
	gitRun(t, work, "checkout", "-q", "-b", "release/v1.2.3")
	gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "ship v1.2.3")
	gitRun(t, work, "checkout", "-q", "main")
	gitRun(t, work, "clone", "-q", "--bare", work, filepath.Join(root, "proj.git"))

	// A bare tap repo to receive the formula.
	tap := filepath.Join(root, "homebrew-tap.git")
	gitRun(t, root, "init", "-q", "--bare", "homebrew-tap.git")
	// Seed the tap with one commit so HEAD exists for cloning.
	tapWork := filepath.Join(root, "tapwork")
	gitRun(t, root, "clone", "-q", "homebrew-tap.git", "tapwork")
	if err := os.WriteFile(filepath.Join(tapWork, "README"), []byte("tap\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	gitRun(t, tapWork, "add", ".")
	gitRun(t, tapWork, "commit", "-q", "-m", "seed")
	gitRun(t, tapWork, "push", "-q", "origin", "HEAD:master")

	dl := filepath.Join(root, "dl")
	ver, err := Publish(Options{
		Store:     gitread.New(root),
		ReposPath: root,
		DLPath:    dl,
		TapRepo:   "homebrew-tap",
		BaseURL:   "https://example.test",
		Repo:      "proj",
		Ref:       "release/v1.2.3", // archive the branch
		Tag:       "v1.2.3",         // version
		Desc:      "a thing",
		Package:   ".",
	})
	if err != nil {
		t.Fatalf("Publish: %v", err)
	}
	if ver != "1.2.3" {
		t.Errorf("ver = %q, want 1.2.3", ver)
	}
	if _, err := os.Stat(filepath.Join(dl, "proj-1.2.3.tar.gz")); err != nil {
		t.Errorf("tarball not written: %v", err)
	}
}

func TestPublishDefaultsRefToTag(t *testing.T) {
	// With Ref empty, Publish must archive Tag (tag-mode back-compat). A bogus
	// repo path makes git archive fail; assert the error names the tag, proving
	// Tag was used as the archive ref.
	_, err := Publish(Options{
		Store: gitread.New(t.TempDir()), ReposPath: t.TempDir(), DLPath: t.TempDir(),
		Repo: "nope", Tag: "v9.9.9",
	})
	if err == nil || !strings.Contains(err.Error(), "v9.9.9") {
		t.Fatalf("expected archive error naming v9.9.9, got %v", err)
	}
}

Run: go test ./internal/release/ -run 'TestPublishArchivesBranchRef|TestPublishDefaultsRefToTag' -v Expected: FAIL — unknown field Ref in struct literal.

In internal/release/release.go, add Ref to Options (after line 81, Repo):

	Repo      string // repo name
	Ref       string // git ref to archive (tag or release branch); defaults to Tag
	Tag       string // vX.Y.Z — the version (tarball name, formula, commit msg)

In Publish (lines 89-104), resolve the archive ref and use it for archive + license:

func Publish(o Options) (string, error) {
	ver := strings.TrimPrefix(o.Tag, "v")
	archiveRef := o.Ref
	if archiveRef == "" {
		archiveRef = o.Tag
	}
	pkg := o.Package
	if pkg == "" {
		pkg = "."
	}
	bare := filepath.Join(o.ReposPath, o.Repo+".git")
	tarName := fmt.Sprintf("%s-%s.tar.gz", o.Repo, ver)

	if err := os.MkdirAll(o.DLPath, 0o755); err != nil {
		return "", err
	}
	tarPath := filepath.Join(o.DLPath, tarName)
	if err := gitArchive(bare, o.Repo+"-"+ver, archiveRef, tarPath); err != nil {
		return "", err
	}

And change the license read (line 117) from o.Tag to archiveRef:

		License:  detectLicense(o.Store, o.Repo, archiveRef),

(The tap commit message at line 185 keeps using o.Tag — the version — which is correct.)

Run: go test ./internal/release/ -v Expected: PASS (all release tests).

git add internal/release/release.go internal/release/release_test.go
git commit -m "feat(release): archive an arbitrary ref, decoupled from the version"

Task 4: Webhook — mode-aware trigger + decision function

Files:

Interfaces:

Create internal/server/release_plan_test.go:

package server

import (
	"testing"

	"custard/internal/release"
)

func TestCouldBeRelease(t *testing.T) {
	yes := []string{"refs/tags/v1.2.3", "refs/heads/release/v1.2.3", "refs/heads/rel/v0.1.0"}
	no := []string{"refs/heads/main", "refs/heads/feature/x", "refs/tags/nightly", "refs/heads/release/main"}
	for _, r := range yes {
		if !couldBeRelease(r) {
			t.Errorf("couldBeRelease(%q) = false, want true", r)
		}
	}
	for _, r := range no {
		if couldBeRelease(r) {
			t.Errorf("couldBeRelease(%q) = true, want false", r)
		}
	}
}

func cfgWith(source, prefix string) release.RepoConfig {
	var c release.RepoConfig
	c.Brew.Enabled = true
	c.Brew.Source = source
	c.Brew.BookmarkPrefix = prefix
	return c
}

func TestReleasePlan(t *testing.T) {
	cases := []struct {
		name, source, prefix, ref string
		wantArchive, wantVer      string
		wantSkip                  bool
	}{
		{"tag mode tag", release.SourceTag, "", "refs/tags/v1.2.3", "v1.2.3", "v1.2.3", false},
		{"tag mode ignores branch", release.SourceTag, "", "refs/heads/release/v1.2.3", "", "", true},
		{"tag mode non-semver tag", release.SourceTag, "", "refs/tags/nightly", "", "", true},
		{"bookmark mode branch", release.SourceBookmark, "", "refs/heads/release/v1.2.3", "release/v1.2.3", "v1.2.3", false},
		{"bookmark mode custom prefix", release.SourceBookmark, "rel/", "refs/heads/rel/v0.1.0", "rel/v0.1.0", "v0.1.0", false},
		{"bookmark mode ignores tag", release.SourceBookmark, "", "refs/tags/v1.2.3", "", "", true},
		{"bookmark mode wrong prefix", release.SourceBookmark, "", "refs/heads/foo/v1.2.3", "", "", true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			archive, ver, skip := releasePlan(cfgWith(c.source, c.prefix), c.ref)
			if (skip != "") != c.wantSkip {
				t.Fatalf("skip = %q, wantSkip=%v", skip, c.wantSkip)
			}
			if !c.wantSkip && (archive != c.wantArchive || ver != c.wantVer) {
				t.Errorf("got archive=%q ver=%q; want %q %q", archive, ver, c.wantArchive, c.wantVer)
			}
		})
	}
}

Run: go test ./internal/server/ -run 'TestCouldBeRelease|TestReleasePlan' -v Expected: FAIL — undefined: couldBeRelease, undefined: releasePlan.

Create internal/server/release_plan.go:

package server

import (
	"strings"

	"custard/internal/release"
)

// couldBeRelease is a cheap, prefix-agnostic prefilter for the webhook: it
// accepts a semver tag or any branch whose final path segment is a release
// semver, so ordinary main/feature pushes never spawn a publish goroutine.
// The exact per-repo mode + prefix are enforced later by releasePlan.
func couldBeRelease(ref string) bool {
	if tag, ok := strings.CutPrefix(ref, "refs/tags/"); ok {
		return release.IsReleaseTag(tag)
	}
	if br, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
		seg := br
		if i := strings.LastIndexByte(br, '/'); i >= 0 {
			seg = br[i+1:]
		}
		return release.IsReleaseTag(seg)
	}
	return false
}

// releasePlan decides, for a repo's config and a pushed ref, what to archive and
// publish. A non-empty skip means "ignore this push" with a logged reason.
func releasePlan(cfg release.RepoConfig, ref string) (archiveRef, version, skip string) {
	switch cfg.ReleaseSource() {
	case release.SourceBookmark:
		br, ok := strings.CutPrefix(ref, "refs/heads/")
		if !ok {
			return "", "", "bookmark mode: not a branch ref"
		}
		ver, ok := release.BookmarkVersion(br, cfg.Prefix())
		if !ok {
			return "", "", "bookmark mode: not a " + cfg.Prefix() + "vX.Y.Z bookmark"
		}
		return br, ver, ""
	default: // SourceTag
		tag, ok := strings.CutPrefix(ref, "refs/tags/")
		if !ok {
			return "", "", "tag mode: not a tag ref"
		}
		if !release.IsReleaseTag(tag) {
			return "", "", "tag mode: not a semver release tag"
		}
		return tag, tag, ""
	}
}

Run: go test ./internal/server/ -run 'TestCouldBeRelease|TestReleasePlan' -v Expected: PASS.

In internal/server/webhook.go, replace the synchronous tag-skip block (lines 54-79) with a prefilter that passes the full ref to the goroutine:

	// Only act on a newly-created ref that could be a release. The exact mode
	// (tag vs bookmark) lives in the repo's .custard.yaml, read in publishRelease
	// once the pushed objects are readable.
	skip := ""
	switch {
	case p.Event != "branch_tag_create" || !p.Created || p.Deleted:
		skip = "not a ref-create"
	case p.Repository.Private:
		skip = "private repo"
	case !couldBeRelease(p.Ref):
		skip = "not a release ref"
	}
	log.Printf("hook: repo=%s ref=%q event=%s created=%v deleted=%v", p.Repository.Name, p.Ref, p.Event, p.Created, p.Deleted)
	if skip != "" {
		log.Printf("hook: ignored (%s) repo=%s ref=%s", skip, p.Repository.Name, p.Ref)
		writeText(w, http.StatusAccepted, "ignored: "+skip)
		return
	}

	repo := p.Repository.Name
	go s.publishRelease(repo, p.Ref, p.Repository.Description)
	writeText(w, http.StatusAccepted, "accepted: "+repo+" "+p.Ref)

Then rewrite publishRelease (lines 84-124) to take the full ref, read config, and apply the mode plan + the mode's guard:

// publishRelease reads the repo's opt-in config (retrying while the freshly
// pushed ref becomes readable) and, if brew-enabled and the highest version for
// its mode, publishes to the tap.
func (s *Server) publishRelease(repo, ref, desc string) {
	// Short ref name to read config at: strip refs/tags/ or refs/heads/.
	readRef := strings.TrimPrefix(strings.TrimPrefix(ref, "refs/tags/"), "refs/heads/")

	var cfg release.RepoConfig
	var ok bool
	for i := 0; i < 30; i++ {
		if cfg, ok = release.ReadRepoConfig(s.store, repo, readRef); ok {
			break
		}
		time.Sleep(time.Second)
	}
	if !ok {
		log.Printf("hook: %s@%s — config never became readable; giving up", repo, ref)
		return
	}
	if !cfg.Brew.Enabled {
		log.Printf("hook: ignored (not brew-enabled) repo=%s ref=%s", repo, ref)
		return
	}

	archiveRef, version, skip := releasePlan(cfg, ref)
	if skip != "" {
		log.Printf("hook: ignored (%s) repo=%s ref=%s", skip, repo, ref)
		return
	}

	// Guard against out-of-order versions downgrading the published formula.
	highest := s.store.IsHighestVersion(repo, version)
	if cfg.ReleaseSource() == release.SourceBookmark {
		highest = s.store.IsHighestRelease(repo, cfg.Prefix(), version)
	}
	if !highest {
		log.Printf("hook: ignored (not highest version; %s superseded) repo=%s", version, repo)
		return
	}

	ver, err := release.Publish(release.Options{
		Store:     s.store,
		ReposPath: s.cfg.ReposPath,
		DLPath:    s.cfg.DLPath,
		TapRepo:   s.cfg.TapRepo,
		BaseURL:   s.cfg.BaseURL,
		Repo:      repo,
		Ref:       archiveRef,
		Tag:       version,
		Desc:      desc,
		Package:   cfg.Brew.Package,
	})
	if err != nil {
		log.Printf("release %s@%s failed: %v", repo, ref, err)
		return
	}
	log.Printf("released %s %s to tap", repo, ver)
}

Run: go build ./... && go test ./internal/server/ ./internal/release/ ./internal/gitread/ -v Expected: PASS, build clean.

git add internal/server/webhook.go internal/server/release_plan.go internal/server/release_plan_test.go
git commit -m "feat(webhook): mode-aware release trigger (tags or bookmarks)"

Task 5: Display — mode-aware version pill + Releases on the refs page

Files:

Interfaces:

Create internal/server/display_test.go:

package server

import (
	"os"
	"os/exec"
	"path/filepath"
	"testing"

	"custard/internal/config"
	"custard/internal/gitread"
)

func gitRun(t *testing.T, dir string, args ...string) {
	t.Helper()
	c := exec.Command("git", args...)
	c.Dir = dir
	c.Env = append(c.Environ(),
		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_AUTHOR_DATE=2026-01-01T00:00:00",
		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", "GIT_COMMITTER_DATE=2026-01-01T00:00:00")
	if out, err := c.CombinedOutput(); err != nil {
		t.Fatalf("git %v: %v: %s", args, err, out)
	}
}

// bookmarkRepo builds a bare "proj.git" in bookmark mode with one release branch.
func bookmarkRepo(t *testing.T) *Server {
	t.Helper()
	root := t.TempDir()
	work := filepath.Join(root, "work")
	gitRun(t, root, "init", "-q", "-b", "main", "work")
	if err := os.WriteFile(filepath.Join(work, ".custard.yaml"),
		[]byte("brew:\n  enabled: true\n  source: bookmark\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	gitRun(t, work, "add", ".")
	gitRun(t, work, "commit", "-q", "-m", "init")
	gitRun(t, work, "checkout", "-q", "-b", "release/v1.2.3")
	gitRun(t, work, "commit", "-q", "--allow-empty", "-m", "ship v1.2.3")
	gitRun(t, work, "checkout", "-q", "main")
	gitRun(t, work, "clone", "-q", "--bare", work, filepath.Join(root, "proj.git"))
	return &Server{store: gitread.New(root), cfg: config.Config{ReposPath: root}}
}

func TestLatestReleaseBookmarkMode(t *testing.T) {
	s := bookmarkRepo(t)
	ref, ok := s.latestRelease("proj")
	if !ok || ref.Name != "v1.2.3" {
		t.Fatalf("latestRelease = %q,%v; want v1.2.3,true", ref.Name, ok)
	}
	if ref.Message != "ship v1.2.3" {
		t.Errorf("label = %q, want tip subject", ref.Message)
	}
}

Note: confirm Server's field names (store, cfg) against internal/server/server.go and adjust the struct literal if they differ. If Server has unexported required deps that make direct construction awkward, instead test latestRelease through the existing server constructor used elsewhere in the package.

Run: go test ./internal/server/ -run TestLatestReleaseBookmarkMode -v Expected: FAIL — s.latestRelease undefined.

In internal/server/server.go, add the helper (near meta):

// latestRelease returns the repo's current version Ref, mode-aware: a release
// bookmark in bookmark mode, otherwise the highest semver tag. The Ref's Message
// is the version label (annotated tag subject, or bookmark tip commit subject).
func (s *Server) latestRelease(name string) (gitread.Ref, bool) {
	branch, err := s.store.DefaultBranch(name)
	if err == nil {
		if cfg, ok := release.ReadRepoConfig(s.store, name, branch); ok && cfg.ReleaseSource() == release.SourceBookmark {
			return s.store.LatestRelease(name, cfg.Prefix())
		}
	}
	return s.store.LatestTag(name)
}

Replace the version block in meta (lines 328-332) with:

	if name != "" {
		if t, ok := s.latestRelease(name); ok {
			m.Version, m.VersionMsg = t.Name, t.Message
		}
	}

(Ensure gitread is imported in server.go; it already is via existing usage of s.store. If the package isn't directly imported, add "custard/internal/gitread".)

Run: go test ./internal/server/ -run TestLatestReleaseBookmarkMode -v Expected: PASS.

In web/templates/templates.templ, add to the RefsPage struct (find type RefsPage):

	Releases []gitread.Ref

(Confirm the templ file already imports custard/internal/gitreadRefsPage.Refs is *gitread.Refs, so it does.)

In the Refs template, after the Branches <ul> (before <h1>Tags</h1>, line 341), add:

		if len(p.Releases) > 0 {
			<h1>Releases</h1>
			<ul class="refs">
				for _, rel := range p.Releases {
					<li>
						<a class="version-pill" href={ templ.SafeURL("/r/" + p.Meta.Repo + "/log/" + rel.Hash) }>{ rel.Name }</a>
						if rel.Message != "" {
							<span class="tag-msg">{ rel.Message }</span>
						}
						<span class="muted"><code>{ ShortHash(rel.Hash) }</code></span>
					</li>
				}
			</ul>
		}

In internal/server/server.go handleRefs (lines 241-249), populate it in bookmark mode:

func (s *Server) handleRefs(w http.ResponseWriter, r *http.Request) {
	name := r.PathValue("repo")
	refs, err := s.store.Refs(name)
	if err != nil {
		s.notFound(w, r)
		return
	}
	page := templates.RefsPage{Meta: s.meta(r, name, "", "refs"), Refs: refs}
	if branch, err := s.store.DefaultBranch(name); err == nil {
		if cfg, ok := release.ReadRepoConfig(s.store, name, branch); ok && cfg.ReleaseSource() == release.SourceBookmark {
			page.Releases, _ = s.store.ReleaseBookmarks(name, cfg.Prefix())
		}
	}
	s.render(w, r, templates.Refs(page))
}

Confirm the exact existing handleRefs body (Meta tab string, RefsPage field names) and preserve them; only add the Releases population.

Run:

templ generate
go build ./... && go test ./internal/server/ -v

Expected: templ regenerates templates_templ.go, build clean, tests PASS.

git add internal/server/server.go internal/server/display_test.go web/templates/templates.templ web/templates/*_templ.go
git commit -m "feat(forge): version pill + refs page recognize release bookmarks"

Task 6: Docs — jj usage guide + migration section + README links

Files:

Interfaces: none (documentation).

Create docs/JUJUTSU.md with these sections (write real prose, not placeholders):

Add a section "Switching a repo from git tags to jj bookmarks":

In README.md after line 177 (the MIGRATION bullet), add:

- **[docs/JUJUTSU.md](docs/JUJUTSU.md)** — using jujutsu (jj) with Soft + custard: bookmark-mode releases and how each jj operation appears in the forge.

Run:

test -f docs/JUJUTSU.md && grep -q "JUJUTSU.md" README.md && grep -qi "bookmark" docs/MIGRATION.md && echo OK

Expected: OK.

git add docs/JUJUTSU.md docs/MIGRATION.md README.md
git commit -m "docs: jj bookmark-mode release guide + git→jj migration section"

Self-Review

Spec coverage (vs docs/superpowers/specs/2026-06-18-jj-bookmark-releases-design.md):

Known scope limit (deliberate, documented): the repo index list version (gitread.summaryLatestTag, templates.templ:152) stays tag-only. Making it mode-aware would require gitread to read .custard.yaml (a gitread → release import cycle) or per-repo config reads on the index. The mode-aware version pill lives on each repo's own page (meta) and refs page, which is the primary surface. Note this in docs/JUJUTSU.md if it surprises.

Placeholder scan: no TBD/TODO; every code step shows complete code. ✓

Type consistency: Options.Ref/Options.Tag, ReleaseSource()/Prefix(), BookmarkVersion, ReleaseBookmarks/LatestRelease/IsHighestRelease, couldBeRelease/releasePlan, latestRelease, RefsPage.Releases — names used identically across tasks. ✓

Caveat to verify during execution: the Server struct field names (store, cfg) and handleRefs body are assumed from reads of internal/server/server.go; confirm before editing and adjust literals/Meta tab strings to match.