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.go +19−23
| @@ -198,26 +198,29 @@ func (a *ACL) RoleFor(user string) Role { | |||
| 198 | 198 | func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) } | |
| 199 | 199 | ||
| 200 | 200 | // ContributorBranchPrefix is the only branch namespace a contributor may push. | |
| 201 | // The account id is in the path, so one contributor cannot move another's branch | ||
| 202 | // and a maintainer can see whose work a ref belongs to without a join. | ||
| 203 | func ContributorBranchPrefix(accountID string) string { | ||
| 204 | return "refs/heads/contrib/" + accountID + "/" | ||
| 201 | // The account name is in the path: it is chosen by the person, fixed at creation | ||
| 202 | // and never reissued, so one contributor cannot move another's branch and a | ||
| 203 | // maintainer can see whose work a ref belongs to without a join. No rename can | ||
| 204 | // strand a branch, because there is nothing to rename. | ||
| 205 | func ContributorBranchPrefix(name string) string { | ||
| 206 | return "refs/heads/contrib/" + name + "/" | ||
| 205 | 207 | } | |
| 206 | 208 | ||
| 207 | // IsContributorRef reports whether ref is a non-empty branch inside accountID's | ||
| 208 | // namespace. | ||
| 209 | func IsContributorRef(accountID, ref string) bool { | ||
| 210 | if accountID == "" { | ||
| 209 | // IsContributorRef reports whether ref is a non-empty branch inside name's | ||
| 210 | // namespace. name is the account name, never a display name. | ||
| 211 | func IsContributorRef(name, ref string) bool { | ||
| 212 | if name == "" { | ||
| 211 | 213 | return false | |
| 212 | 214 | } | |
| 213 | prefix := ContributorBranchPrefix(accountID) | ||
| 215 | prefix := ContributorBranchPrefix(name) | ||
| 214 | 216 | return strings.HasPrefix(ref, prefix) && len(ref) > len(prefix) | |
| 215 | 217 | } | |
| 216 | 218 | ||
| 217 | // ContributorRefAccount returns the account id embedded in a contributor ref. | ||
| 219 | // ContributorRefName returns the account name embedded in a contributor ref. | ||
| 218 | 220 | // It is the reverse of ContributorBranchPrefix, used by the CI gate before it | |
| 219 | // has a principal in hand. | ||
| 220 | func ContributorRefAccount(ref string) (accountID string, ok bool) { | ||
| 221 | // has a principal in hand. The name is a path component, so it is returned as | ||
| 222 | // written; resolving it to an account is the roster's job. | ||
| 223 | func ContributorRefName(ref string) (name string, ok bool) { | ||
| 221 | 224 | const prefix = "refs/heads/contrib/" | |
| 222 | 225 | if !strings.HasPrefix(ref, prefix) { | |
| 223 | 226 | return "", false | |
| @@ -238,19 +241,12 @@ func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Cont | |||
| 238 | 241 | // branch the caller may push: the web checks the contributor namespace. | |
| 239 | 242 | func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contributor) } | |
| 240 | 243 | ||
| 241 | // CanPush says the account may push somewhere. Use CanPushRef to ask about a | ||
| 242 | // particular ref; a contributor may only push their own namespace. | ||
| 244 | // CanPush says the account may push somewhere. Which refs a contributor may | ||
| 245 | // write is a namespace question and needs the account *name*, not the id the ACL | ||
| 246 | // is keyed by, so that half of the rule lives where the push is gate-kept: the | ||
| 247 | // pre-receive hook, which has the name the SSH key resolved to. | ||
| 243 | 248 | func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contributor) } | |
| 244 | 249 | ||
| 245 | // CanPushRef is the ref-level answer the pre-receive gate needs. | ||
| 246 | func (a *ACL) CanPushRef(user, ref string) bool { | ||
| 247 | role := a.RoleFor(user) | ||
| 248 | if role.AtLeast(Maintain) { | ||
| 249 | return true | ||
| 250 | } | ||
| 251 | return role == Contributor && IsContributorRef(user, ref) | ||
| 252 | } | ||
| 253 | |||
| 254 | 250 | // CanTriage covers changing somebody else's issue: labels, assignees, state. | |
| 255 | 251 | func (a *ACL) CanTriage(user string) bool { return a.RoleFor(user).AtLeast(Maintain) } | |
| 256 | 252 | ||