vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Name branches after the account, and labels after the display
474a812d199a109ba894124cd6f26be12c73285d · Keenan Nemetz · 16d ago · parent ade6107e
Name branches after the account, and labels after the display
A contributor's folder was `refs/heads/contrib/<account-id>/`, which told a
reader nothing without decoding a ULID. It is now the account name,
refs/heads/contrib/<name>/<branch>
and the name is therefore fixed at creation: renaming an account would strand
every ref under its old folder, so `vole admin user rename` is gone and the
users row itself — kept after retirement — is what stops a name being reissued.
What people see is now a separate, mutable display name, validated by the same
rules wherever it is set (CLI, remote admin, registration, `/account`) and shown
on pages, approvals, listings and refusals. Nothing is authorized by it and
nothing on disk is keyed by it: metadata authors stay account names because the
push boundary resolves them for ownership, and approvals stay account ids.
`user_names`, `Snapshot.Reserved` and `Account.Former` are dropped: with a fixed
name there are no former names to remember, and the UNIQUE column already does
the reserving. Opening an older roster adds the `display` column and drops the
alias table in place; contributor refs written under the old id namespace are
left where they are, readable, and landable by a maintainer.
forge/access/acl_test.go +15−9
| @@ -103,16 +103,15 @@ func TestCapabilitiesFollowTheRole(t *testing.T) { | |||
| 103 | 103 | ||
| 104 | 104 | func TestContributorRefNamespace(t *testing.T) { | |
| 105 | 105 | t.Parallel() | |
| 106 | a := &ACL{Entries: map[string]string{"alice": "contributor"}} | ||
| 107 | 106 | own := "refs/heads/contrib/alice/topic" | |
| 108 | if id, ok := ContributorRefAccount(own); !ok || id != "alice" { | ||
| 109 | t.Fatalf("ContributorRefAccount(%q)=%q,%v", own, id, ok) | ||
| 107 | if name, ok := ContributorRefName(own); !ok || name != "alice" { | ||
| 108 | t.Fatalf("ContributorRefName(%q)=%q,%v", own, name, ok) | ||
| 110 | 109 | } | |
| 111 | if _, ok := ContributorRefAccount("refs/heads/contrib/alice"); ok { | ||
| 110 | if _, ok := ContributorRefName("refs/heads/contrib/alice"); ok { | ||
| 112 | 111 | t.Fatal("an empty contributor namespace parsed as a branch") | |
| 113 | 112 | } | |
| 114 | if !a.CanPushRef("alice", own) { | ||
| 115 | t.Fatalf("contributor cannot push %s", own) | ||
| 113 | if !IsContributorRef("alice", own) { | ||
| 114 | t.Fatalf("alice cannot push %s", own) | ||
| 116 | 115 | } | |
| 117 | 116 | for _, ref := range []string{ | |
| 118 | 117 | "refs/heads/main", | |
| @@ -121,13 +120,20 @@ func TestContributorRefNamespace(t *testing.T) { | |||
| 121 | 120 | "refs/tags/contrib/alice/v1", | |
| 122 | 121 | "refs/heads/vole", | |
| 123 | 122 | } { | |
| 124 | if a.CanPushRef("alice", ref) { | ||
| 125 | t.Errorf("contributor was allowed to push %s", ref) | ||
| 123 | if IsContributorRef("alice", ref) { | ||
| 124 | t.Errorf("alice was allowed to push %s", ref) | ||
| 126 | 125 | } | |
| 127 | 126 | } | |
| 128 | if a.CanPushRef("bob", own) { | ||
| 127 | if IsContributorRef("bob", own) { | ||
| 129 | 128 | t.Fatal("a different account could push alice's namespace") | |
| 130 | 129 | } | |
| 130 | // A name is a whole path component: alice's namespace is not alice-b's. | ||
| 131 | if IsContributorRef("alice", "refs/heads/contrib/alice-b/topic") { | ||
| 132 | t.Fatal("the namespace prefix matched a longer account name") | ||
| 133 | } | ||
| 134 | if got := ContributorBranchPrefix("alice"); got != "refs/heads/contrib/alice/" { | ||
| 135 | t.Fatalf("ContributorBranchPrefix = %q", got) | ||
| 136 | } | ||
| 131 | 137 | } | |
| 132 | 138 | ||
| 133 | 139 | func TestDefaultAppliesToUnknownUsers(t *testing.T) { | |