diff --git a/appview/pages/forkstatus_test.go b/appview/pages/forkstatus_test.go
new file mode 100644
index 00000000..fe81a8f2
--- /dev/null
+++ b/appview/pages/forkstatus_test.go
@@ -0,0 +1,138 @@
+package pages
+
+import (
+ "bytes"
+ "log/slog"
+ "strings"
+ "testing"
+
+ "tangled.org/core/appview/config"
+ "tangled.org/core/appview/pages/repoinfo"
+ "tangled.org/core/idresolver"
+ "tangled.org/core/types"
+)
+
+// The fork status fragment is rendered from a lazily-loaded htmx request, so a
+// template error surfaces at runtime rather than at build time. These tests
+// parse and execute it for each status the handler can produce.
+func renderForkStatus(t *testing.T, status *types.ForkStatusResult) string {
+ t.Helper()
+
+ p := NewPages(&config.Config{}, idresolver.DefaultResolver(""), nil, nil, slog.Default())
+
+ var buf bytes.Buffer
+ err := p.ForkStatusFragment(&buf, ForkStatusParams{
+ RepoInfo: repoinfo.RepoInfo{
+ OwnerHandle: "alice",
+ Name: "repo",
+ },
+ Status: status,
+ })
+ if err != nil {
+ t.Fatalf("rendering fork status fragment: %v", err)
+ }
+
+ return buf.String()
+}
+
+func TestForkStatusFragment_FastForwardable(t *testing.T) {
+ out := renderForkStatus(t, &types.ForkStatusResult{
+ Status: types.FastForwardable,
+ Behind: 3,
+ Branch: "main",
+ })
+
+ if !strings.Contains(out, "behind by 3 commits") {
+ t.Errorf("expected commit count in output, got:\n%s", out)
+ }
+ if !strings.Contains(out, "Sync fork") {
+ t.Errorf("expected sync button in output, got:\n%s", out)
+ }
+ if !strings.Contains(out, `value="main"`) {
+ t.Errorf("expected branch to be posted back, got:\n%s", out)
+ }
+}
+
+// htmx puts the htmx-request class on whichever element carries hx-post, and
+// the compiled selector is `.group\/form.htmx-request .group-[...]\/form:*` --
+// both classes have to land on that same element. Keying the spinner off a
+// bare `group` on the button silently never fires.
+func TestForkStatusFragment_SpinnerIsWiredToTheForm(t *testing.T) {
+ out := renderForkStatus(t, &types.ForkStatusResult{
+ Status: types.FastForwardable,
+ Behind: 3,
+ Branch: "main",
+ })
+
+ if !strings.Contains(out, "group/form") {
+ t.Errorf("form must carry the named group the spinner keys off, got:\n%s", out)
+ }
+ if !strings.Contains(out, "group-[.htmx-request]/form:inline") {
+ t.Errorf("expected a spinner shown during the request, got:\n%s", out)
+ }
+ if !strings.Contains(out, "group-[.htmx-request]/form:hidden") {
+ t.Errorf("expected the idle icon hidden during the request, got:\n%s", out)
+ }
+ if !strings.Contains(out, "hx-disabled-elt") {
+ t.Errorf("expected the button disabled during the request, got:\n%s", out)
+ }
+
+ // a bare `group` on the button would not match the compiled selector
+ if strings.Contains(out, `class="btn group text-sm`) {
+ t.Errorf("spinner keyed off a bare group on the button will never fire, got:\n%s", out)
+ }
+}
+
+func TestForkStatusFragment_SingularCommit(t *testing.T) {
+ out := renderForkStatus(t, &types.ForkStatusResult{
+ Status: types.FastForwardable,
+ Behind: 1,
+ Branch: "main",
+ })
+
+ if !strings.Contains(out, "behind by 1 commit") {
+ t.Errorf("expected singular wording, got:\n%s", out)
+ }
+ if strings.Contains(out, "1 commits") {
+ t.Errorf("expected singular wording, got plural:\n%s", out)
+ }
+}
+
+func TestForkStatusFragment_Conflict(t *testing.T) {
+ out := renderForkStatus(t, &types.ForkStatusResult{
+ Status: types.Conflict,
+ Branch: "main",
+ })
+
+ if !strings.Contains(out, "diverged") {
+ t.Errorf("expected divergence notice, got:\n%s", out)
+ }
+ if strings.Contains(out, "Sync fork") {
+ t.Errorf("a diverged fork must not offer a sync button, got:\n%s", out)
+ }
+}
+
+func TestForkStatusFragment_UpToDate(t *testing.T) {
+ out := renderForkStatus(t, &types.ForkStatusResult{
+ Status: types.UpToDate,
+ Branch: "main",
+ })
+
+ if !strings.Contains(out, "up to date") {
+ t.Errorf("expected up to date notice, got:\n%s", out)
+ }
+ if strings.Contains(out, "Sync fork") {
+ t.Errorf("an up to date fork must not offer a sync button, got:\n%s", out)
+ }
+}
+
+func TestForkStatusFragment_MissingBranch(t *testing.T) {
+ out := renderForkStatus(t, &types.ForkStatusResult{
+ Status: types.MissingBranch,
+ Branch: "main",
+ })
+
+ if strings.Contains(out, "Sync fork") {
+ t.Errorf("a missing branch must not offer a sync button, got:\n%s", out)
+ }
+}
diff --git a/appview/pages/pages.go b/appview/pages/pages.go
index e20d77af..dddbd6f0 100644
--- a/appview/pages/pages.go
+++ b/appview/pages/pages.go
@@ -1761,6 +1761,15 @@ func (p *Pages) PipelineStatusesFragment(w io.Writer, params PipelineStatusesPar
return p.executePlain("repo/fragments/pipelineStatuses", w, params)
}
+type ForkStatusParams struct {
+ RepoInfo repoinfo.RepoInfo
+ Status *types.ForkStatusResult
+}
+
+func (p *Pages) ForkStatusFragment(w io.Writer, params ForkStatusParams) error {
+ return p.executePlain("repo/fragments/forkStatus", w, params)
+}
+
type WorkflowParams struct {
BaseParams
RepoInfo repoinfo.RepoInfo
diff --git a/appview/pages/templates/layouts/repobase.html b/appview/pages/templates/layouts/repobase.html
index 82f0bd03..dbfd16a8 100644
--- a/appview/pages/templates/layouts/repobase.html
+++ b/appview/pages/templates/layouts/repobase.html
@@ -81,6 +81,14 @@
{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}
+ {{ if .RepoInfo.Roles.IsOwner }}
+
+ {{ end }}
{{ end }}
{{ end }}
diff --git a/appview/pages/templates/repo/fragments/forkStatus.html b/appview/pages/templates/repo/fragments/forkStatus.html
new file mode 100644
index 00000000..c6535c89
--- /dev/null
+++ b/appview/pages/templates/repo/fragments/forkStatus.html
@@ -0,0 +1,39 @@
+{{ define "repo/fragments/forkStatus" }}
+ {{ $status := .Status }}
+ {{ $repo := .RepoInfo }}
+
+ {{ if $status.IsFastForwardable }}
+
+ · behind by {{ $status.Behind }} commit{{ if ne $status.Behind 1 }}s{{ end }}
+
+ {{/* the request class lands on the element carrying hx-post, so the
+ spinner has to key off a named group on the form itself */}}
+
+ {{ else if $status.IsConflict }}
+ · has diverged from upstream
+
+ compare
+
+ {{ else if $status.IsUpToDate }}
+ · up to date
+ {{ end }}
+
+{{ end }}
diff --git a/appview/repo/forkstatus.go b/appview/repo/forkstatus.go
new file mode 100644
index 00000000..eece21b8
--- /dev/null
+++ b/appview/repo/forkstatus.go
@@ -0,0 +1,154 @@
+package repo
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "tangled.org/core/api/tangled"
+ "tangled.org/core/appview/pages"
+ "tangled.org/core/types"
+)
+
+// forkStatusWindow caps how far back we walk a branch's history looking for the
+// other side's head. Past this we can't tell "very far behind" from "diverged",
+// so we report neither and point the user at the compare view instead.
+//
+// The mirror's listCommits lexicon caps limit at 100.
+const forkStatusWindow = 100
+
+// ForkStatus compares a fork's branch against the same branch on its upstream
+// source and renders a fragment describing the difference.
+//
+// This is loaded lazily over htmx rather than computed during the repo page
+// render: it costs two to four calls to the knot mirror, and the mirror trails
+// the knot by a second or two after a push.
+func (rp *Repo) ForkStatus(w http.ResponseWriter, r *http.Request) {
+ l := rp.logger.With("handler", "ForkStatus")
+
+ f, err := rp.repoResolver.Resolve(r)
+ if err != nil {
+ l.Error("failed to resolve repo", "err", err)
+ return
+ }
+
+ user := rp.oauth.GetMultiAccountUser(r)
+ repoInfo := rp.repoResolver.GetRepoInfo(r, user)
+
+ // nothing to compare against
+ if repoInfo.Source == nil {
+ return
+ }
+
+ branch := r.URL.Query().Get("branch")
+ if branch == "" {
+ branch, err = rp.defaultBranch(r.Context(), f.RepoDid)
+ if err != nil {
+ l.Warn("failed to resolve default branch", "err", err)
+ return
+ }
+ }
+
+ status, err := rp.compareFork(r.Context(), f.RepoDid, repoInfo.Source.RepoDid, branch)
+ if err != nil {
+ // A fork the mirror hasn't cloned yet is the common case here, and a
+ // fresh fork is up to date with its source by construction. Render
+ // nothing rather than an error.
+ l.Warn("failed to compare fork against source", "err", err)
+ return
+ }
+
+ if err := rp.pages.ForkStatusFragment(w, pages.ForkStatusParams{
+ RepoInfo: repoInfo,
+ Status: status,
+ }); err != nil {
+ l.Error("failed to render fork status", "err", err)
+ }
+}
+
+// compareFork determines how a fork's branch relates to the same branch on its
+// source, using only the mirror's existing read endpoints.
+func (rp *Repo) compareFork(ctx context.Context, forkDid, sourceDid, branch string) (*types.ForkStatusResult, error) {
+ result := &types.ForkStatusResult{Branch: branch}
+
+ forkHash, err := rp.branchHash(ctx, forkDid, branch)
+ if err != nil {
+ return nil, fmt.Errorf("resolving fork branch: %w", err)
+ }
+
+ sourceHash, err := rp.branchHash(ctx, sourceDid, branch)
+ if err != nil {
+ // the source may simply not carry this branch
+ result.Status = types.MissingBranch
+ return result, nil
+ }
+
+ if forkHash == sourceHash {
+ result.Status = types.UpToDate
+ return result, nil
+ }
+
+ // Is the fork's head in the source's recent history? If so the fork is
+ // strictly behind and a fast-forward is safe.
+ if behind, found, err := rp.distanceTo(ctx, sourceDid, branch, forkHash); err != nil {
+ return nil, err
+ } else if found {
+ result.Status = types.FastForwardable
+ result.Behind = behind
+ return result, nil
+ }
+
+ // Otherwise, is the source's head in the fork's recent history? Then the
+ // fork is ahead and there is nothing to pull in.
+ if _, found, err := rp.distanceTo(ctx, forkDid, branch, sourceHash); err != nil {
+ return nil, err
+ } else if found {
+ result.Status = types.UpToDate
+ return result, nil
+ }
+
+ // Neither head appears in the other's window: the branches have diverged,
+ // or drifted further apart than we looked. Either way a plain sync is not
+ // safe, so offer a comparison rather than a button.
+ result.Status = types.Conflict
+ return result, nil
+}
+
+// branchHash returns the commit hash at the tip of a branch.
+func (rp *Repo) branchHash(ctx context.Context, repoDid, branch string) (string, error) {
+ out, err := tangled.GitTempGetBranch(ctx, rp.knotMirrorXRPCClient(), branch, repoDid)
+ if err != nil {
+ return "", err
+ }
+ return out.Hash, nil
+}
+
+// distanceTo walks a branch's recent history looking for target, returning how
+// many commits precede it. found is false if target isn't within the window.
+func (rp *Repo) distanceTo(ctx context.Context, repoDid, branch, target string) (int, bool, error) {
+ xrpcBytes, err := tangled.GitTempListCommits(
+ ctx,
+ rp.knotMirrorXRPCClient(),
+ "",
+ forkStatusWindow,
+ branch,
+ repoDid,
+ )
+ if err != nil {
+ return 0, false, fmt.Errorf("listing commits: %w", err)
+ }
+
+ var resp types.RepoLogResponse
+ if err := json.Unmarshal(xrpcBytes, &resp); err != nil {
+ return 0, false, fmt.Errorf("decoding commits: %w", err)
+ }
+
+ for i, c := range resp.Commits {
+ if c.Hash.String() == target {
+ return i, true, nil
+ }
+ }
+
+ return 0, false, nil
+}
diff --git a/appview/repo/forkstatus_test.go b/appview/repo/forkstatus_test.go
new file mode 100644
index 00000000..3e18921b
--- /dev/null
+++ b/appview/repo/forkstatus_test.go
@@ -0,0 +1,221 @@
+package repo
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/bluesky-social/indigo/util"
+ indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+ "github.com/go-git/go-git/v5/plumbing"
+ "tangled.org/core/types"
+)
+
+const (
+ forkDid = "did:plc:fork"
+ sourceDid = "did:plc:source"
+ branch = "main"
+)
+
+// fakeMirror stands in for knotmirror, serving the two read endpoints that fork
+// status is derived from. Responses are built from types.RepoLogResponse and
+// marshalled here so the wire encoding matches what the real handler produces
+// (notably plumbing.Hash, which is a byte array rather than a hex string).
+type fakeMirror struct {
+ // heads maps a repo DID to the commit at the tip of branch. A missing entry
+ // makes getBranch fail, as it does when the branch isn't there.
+ heads map[string]string
+ // history maps a repo DID to that branch's commits, newest first.
+ history map[string][]string
+}
+
+func (f *fakeMirror) server(t *testing.T) *httptest.Server {
+ t.Helper()
+
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/xrpc/sh.tangled.git.temp.getBranch", func(w http.ResponseWriter, r *http.Request) {
+ repo := r.URL.Query().Get("repo")
+ head, ok := f.heads[repo]
+ if !ok {
+ http.Error(w, `{"error":"BranchNotFound"}`, http.StatusNotFound)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "name": r.URL.Query().Get("name"),
+ "hash": head,
+ "when": "2026-01-01T00:00:00Z",
+ })
+ })
+
+ mux.HandleFunc("/xrpc/sh.tangled.git.temp.listCommits", func(w http.ResponseWriter, r *http.Request) {
+ repo := r.URL.Query().Get("repo")
+ var commits []types.Commit
+ for _, h := range f.history[repo] {
+ commits = append(commits, types.Commit{Hash: plumbing.NewHash(h)})
+ }
+ json.NewEncoder(w).Encode(types.RepoLogResponse{Commits: commits})
+ })
+
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func (f *fakeMirror) repo(t *testing.T) *Repo {
+ t.Helper()
+ srv := f.server(t)
+ return &Repo{
+ knotMirrorXRPC: &indigoxrpc.Client{
+ Host: srv.URL,
+ Client: util.RobustHTTPClient(),
+ },
+ }
+}
+
+// sha builds a distinguishable 40-char hex hash from a short label.
+func sha(label string) string {
+ return strings.Repeat(label, 40)[:40]
+}
+
+func TestCompareFork(t *testing.T) {
+ var (
+ base = sha("a")
+ mid = sha("b")
+ tip = sha("c")
+ alt = sha("d")
+ )
+
+ tests := []struct {
+ name string
+ mirror fakeMirror
+ wantStatus types.ForkStatus
+ wantBehind int
+ }{
+ {
+ name: "identical heads are up to date",
+ mirror: fakeMirror{
+ heads: map[string]string{forkDid: tip, sourceDid: tip},
+ },
+ wantStatus: types.UpToDate,
+ },
+ {
+ name: "fork strictly behind is fast forwardable",
+ mirror: fakeMirror{
+ heads: map[string]string{forkDid: base, sourceDid: tip},
+ // source history newest first: tip, mid, base
+ history: map[string][]string{sourceDid: {tip, mid, base}},
+ },
+ wantStatus: types.FastForwardable,
+ wantBehind: 2,
+ },
+ {
+ name: "fork behind by one",
+ mirror: fakeMirror{
+ heads: map[string]string{forkDid: mid, sourceDid: tip},
+ history: map[string][]string{sourceDid: {tip, mid}},
+ },
+ wantStatus: types.FastForwardable,
+ wantBehind: 1,
+ },
+ {
+ name: "fork ahead of source has nothing to pull",
+ mirror: fakeMirror{
+ heads: map[string]string{forkDid: tip, sourceDid: base},
+ // source doesn't contain the fork's head...
+ history: map[string][]string{
+ sourceDid: {base},
+ // ...but the fork contains the source's head
+ forkDid: {tip, mid, base},
+ },
+ },
+ wantStatus: types.UpToDate,
+ },
+ {
+ name: "diverged branches conflict",
+ mirror: fakeMirror{
+ heads: map[string]string{forkDid: alt, sourceDid: tip},
+ history: map[string][]string{
+ sourceDid: {tip, mid, base},
+ forkDid: {alt, base},
+ },
+ },
+ wantStatus: types.Conflict,
+ },
+ {
+ name: "source missing the branch",
+ mirror: fakeMirror{
+ heads: map[string]string{forkDid: tip},
+ },
+ wantStatus: types.MissingBranch,
+ },
+ {
+ name: "further apart than the window conflicts",
+ mirror: fakeMirror{
+ heads: map[string]string{forkDid: base, sourceDid: tip},
+ // neither history mentions the other's head
+ history: map[string][]string{
+ sourceDid: {tip, mid},
+ forkDid: {base},
+ },
+ },
+ wantStatus: types.Conflict,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rp := tt.mirror.repo(t)
+
+ got, err := rp.compareFork(context.Background(), forkDid, sourceDid, branch)
+ if err != nil {
+ t.Fatalf("compareFork: %v", err)
+ }
+
+ if got.Status != tt.wantStatus {
+ t.Errorf("status = %v, want %v", got.Status, tt.wantStatus)
+ }
+ if got.Status == types.FastForwardable && got.Behind != tt.wantBehind {
+ t.Errorf("behind = %d, want %d", got.Behind, tt.wantBehind)
+ }
+ if got.Branch != branch {
+ t.Errorf("branch = %q, want %q", got.Branch, branch)
+ }
+ })
+ }
+}
+
+func TestCompareFork_ForkBranchMissingIsAnError(t *testing.T) {
+ // the fork not having the branch is different from the source not having
+ // it: we can't say anything useful, so the caller renders nothing
+ m := fakeMirror{heads: map[string]string{sourceDid: sha("c")}}
+ rp := m.repo(t)
+
+ if _, err := rp.compareFork(context.Background(), forkDid, sourceDid, branch); err == nil {
+ t.Error("expected an error when the fork lacks the branch")
+ }
+}
+
+// A diverged fork must never be reported as fast-forwardable: that is the one
+// mistake that would let the UI offer a button which discards commits.
+func TestCompareFork_DivergedIsNeverFastForwardable(t *testing.T) {
+ m := fakeMirror{
+ heads: map[string]string{forkDid: sha("d"), sourceDid: sha("c")},
+ history: map[string][]string{
+ sourceDid: {sha("c"), sha("b")},
+ forkDid: {sha("d"), sha("b")},
+ },
+ }
+ rp := m.repo(t)
+
+ got, err := rp.compareFork(context.Background(), forkDid, sourceDid, branch)
+ if err != nil {
+ t.Fatalf("compareFork: %v", err)
+ }
+ if got.Status == types.FastForwardable {
+ t.Fatal("diverged fork reported as fast forwardable; sync button would discard commits")
+ }
+}
diff --git a/appview/repo/repo.go b/appview/repo/repo.go
index f899b9d3..2ea5f7a9 100644
--- a/appview/repo/repo.go
+++ b/appview/repo/repo.go
@@ -1419,7 +1419,7 @@ func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
client,
&tangled.RepoForkSync_Input{
Repo: f.RepoDid,
- Did: &user.Did,
+ Did: &f.Did,
Name: &f.Name,
Source: &f.Source,
Branch: ref,
@@ -1431,7 +1431,20 @@ func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
return
}
- rp.pages.HxRefresh(w)
+ // Report success straight away rather than re-reading the status. The
+ // knot is authoritative and has accepted the fast-forward, but the knot
+ // mirror we'd read back from trails it by a second or two, so asking
+ // again here would usually still say "behind".
+ repoInfo := rp.repoResolver.GetRepoInfo(r, user)
+ if err := rp.pages.ForkStatusFragment(w, pages.ForkStatusParams{
+ RepoInfo: repoInfo,
+ Status: &types.ForkStatusResult{
+ Status: types.UpToDate,
+ Branch: ref,
+ },
+ }); err != nil {
+ l.Error("failed to render fork status", "err", err)
+ }
return
}
}
diff --git a/appview/repo/router.go b/appview/repo/router.go
index c3b053ba..fe796d8f 100644
--- a/appview/repo/router.go
+++ b/appview/repo/router.go
@@ -54,6 +54,7 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler {
r.Use(middleware.AuthMiddleware(rp.oauth))
r.Get("/", rp.ForkRepo)
r.Post("/", rp.ForkRepo)
+ r.With(mw.RepoPermissionMiddleware("repo:owner")).Get("/status", rp.ForkStatus)
r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sync", func(r chi.Router) {
r.Post("/", rp.SyncRepoFork)
})
diff --git a/types/repo.go b/types/repo.go
index 58bd999e..a1e5513e 100644
--- a/types/repo.go
+++ b/types/repo.go
@@ -95,6 +95,20 @@ type ForkInfo struct {
Status ForkStatus
}
+// ForkStatusResult describes how a fork's branch relates to the same branch on
+// its upstream source.
+type ForkStatusResult struct {
+ Status ForkStatus
+ // Behind is how many commits the fork trails the source by, and is only
+ // meaningful when Status is FastForwardable.
+ Behind int
+ Branch string
+}
+
+func (f ForkStatusResult) IsUpToDate() bool { return f.Status == UpToDate }
+func (f ForkStatusResult) IsFastForwardable() bool { return f.Status == FastForwardable }
+func (f ForkStatusResult) IsConflict() bool { return f.Status == Conflict }
+
type RepoLanguageDetails struct {
Name string
Percentage float32