detached e0bea3d

feat(domain): add branch prefixes to tokens

commit
e0bea3d9ab59466be48fffde9c145e02d5631f4c
author
brice · · 2026-10-12 14:48 UTC
parent
fc4d564
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +193 -15
M internal/domain/principal.go +46 -15
@@ -4,6 +4,7 @@ package domain
44
55 import (
66 "slices"
7+ "strings"
78 "time"
89 )
910
@@ -22,12 +23,14 @@ const (
2223 // token, the owner signed in on the web, or a stored token with its scopes
2324 // and repositories.
2425 type Principal struct {
25- kind principalKind
26- tokenID string
27- scopes []Scope
28- repos []RepoName
29- expiresAt time.Time
30- label string
26+ kind principalKind
27+ tokenID string
28+ scopes []Scope
29+ repos []RepoName
30+ // branchPrefixes limits the branches a token may write; empty = all.
31+ branchPrefixes []string
32+ expiresAt time.Time
33+ label string
3134 }
3235
3336 // Anonymous returns the principal of a caller without credentials.
@@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} }
4447 // TokenPrincipal returns the principal of the holder of a stored token.
4548 func TokenPrincipal(t Token) Principal {
4649 return Principal{
47- kind: kindToken,
48- tokenID: t.ID,
49- scopes: slices.Clone(t.Scopes),
50- repos: slices.Clone(t.Repos),
51- expiresAt: t.ExpiresAt,
52- label: t.Label,
50+ kind: kindToken,
51+ tokenID: t.ID,
52+ scopes: slices.Clone(t.Scopes),
53+ repos: slices.Clone(t.Repos),
54+ branchPrefixes: slices.Clone(t.BranchPrefixes),
55+ expiresAt: t.ExpiresAt,
56+ label: t.Label,
5357 }
5458 }
5559
@@ -72,13 +76,13 @@ func (p Principal) Label() string {
7276 func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }
7377
7478 // HasOwnerRights reports whether p is the owner: root, the owner's web
75-// session, or a stored token that holds every scope and is not limited to a
76-// list of repositories. It decides who may manage browser sessions.
79+// session, or a stored token that holds every scope and is limited neither to
80+// a list of repositories nor to branch prefixes. It decides who may manage browser sessions.
7781 func (p Principal) HasOwnerRights() bool {
7882 if p.IsRoot() {
7983 return true
8084 }
81- if p.kind != kindToken || len(p.repos) > 0 {
85+ if p.kind != kindToken || len(p.repos) > 0 || len(p.branchPrefixes) > 0 {
8286 return false
8387 }
8488 for _, s := range AllScopes() {
@@ -105,6 +109,33 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
105109 // repository.
106110 func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
107111
112+// BranchPrefixes returns the branch prefixes of a stored token; empty means
113+// every branch.
114+func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) }
115+
116+// AllowsRef reports whether p may write the ref (create, update or delete
117+// it). Without branch prefixes every ref is allowed. With them, only a branch
118+// "refs/heads/<name>" under one of the prefixes is: tags, every other ref and
119+// a branch outside the prefixes are not.
120+func (p Principal) AllowsRef(ref string) bool {
121+ if len(p.branchPrefixes) == 0 {
122+ return true
123+ }
124+ name, ok := strings.CutPrefix(ref, "refs/heads/")
125+ if !ok {
126+ return false
127+ }
128+ if _, err := ParseBranchName(name); err != nil {
129+ return false
130+ }
131+ for _, prefix := range p.branchPrefixes {
132+ if strings.HasPrefix(name, prefix) {
133+ return true
134+ }
135+ }
136+ return false
137+}
138+
108139 // ExpiresAt returns the expiry of a stored token; zero means none.
109140 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
110141
@@ -4,6 +4,7 @@ package domain
4 4
5 import (5 import (
6 "slices"6 "slices"
7+ "strings"
7 "time"8 "time"
8 )9 )
9 10
@@ -22,12 +23,14 @@ const (
22 // token, the owner signed in on the web, or a stored token with its scopes23 // token, the owner signed in on the web, or a stored token with its scopes
23 // and repositories.24 // and repositories.
24 type Principal struct {25 type Principal struct {
25- kind principalKind26+ kind principalKind
26- tokenID string27+ tokenID string
27- scopes []Scope28+ scopes []Scope
28- repos []RepoName29+ repos []RepoName
29- expiresAt time.Time30+ // branchPrefixes limits the branches a token may write; empty = all.
30- label string31+ branchPrefixes []string
32+ expiresAt time.Time
33+ label string
31 }34 }
32 35
33 // Anonymous returns the principal of a caller without credentials.36 // Anonymous returns the principal of a caller without credentials.
@@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} }
44 // TokenPrincipal returns the principal of the holder of a stored token.47 // TokenPrincipal returns the principal of the holder of a stored token.
45 func TokenPrincipal(t Token) Principal {48 func TokenPrincipal(t Token) Principal {
46 return Principal{49 return Principal{
47- kind: kindToken,50+ kind: kindToken,
48- tokenID: t.ID,51+ tokenID: t.ID,
49- scopes: slices.Clone(t.Scopes),52+ scopes: slices.Clone(t.Scopes),
50- repos: slices.Clone(t.Repos),53+ repos: slices.Clone(t.Repos),
51- expiresAt: t.ExpiresAt,54+ branchPrefixes: slices.Clone(t.BranchPrefixes),
52- label: t.Label,55+ expiresAt: t.ExpiresAt,
56+ label: t.Label,
53 }57 }
54 }58 }
55 59
@@ -72,13 +76,13 @@ func (p Principal) Label() string {
72 func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }76 func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }
73 77
74 // HasOwnerRights reports whether p is the owner: root, the owner's web78 // HasOwnerRights reports whether p is the owner: root, the owner's web
75-// session, or a stored token that holds every scope and is not limited to a79+// session, or a stored token that holds every scope and is limited neither to
76-// list of repositories. It decides who may manage browser sessions.80+// a list of repositories nor to branch prefixes. It decides who may manage browser sessions.
77 func (p Principal) HasOwnerRights() bool {81 func (p Principal) HasOwnerRights() bool {
78 if p.IsRoot() {82 if p.IsRoot() {
79 return true83 return true
80 }84 }
81- if p.kind != kindToken || len(p.repos) > 0 {85+ if p.kind != kindToken || len(p.repos) > 0 || len(p.branchPrefixes) > 0 {
82 return false86 return false
83 }87 }
84 for _, s := range AllScopes() {88 for _, s := range AllScopes() {
@@ -105,6 +109,33 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
105 // repository.109 // repository.
106 func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }110 func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
107 111
112+// BranchPrefixes returns the branch prefixes of a stored token; empty means
113+// every branch.
114+func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) }
115+
116+// AllowsRef reports whether p may write the ref (create, update or delete
117+// it). Without branch prefixes every ref is allowed. With them, only a branch
118+// "refs/heads/<name>" under one of the prefixes is: tags, every other ref and
119+// a branch outside the prefixes are not.
120+func (p Principal) AllowsRef(ref string) bool {
121+ if len(p.branchPrefixes) == 0 {
122+ return true
123+ }
124+ name, ok := strings.CutPrefix(ref, "refs/heads/")
125+ if !ok {
126+ return false
127+ }
128+ if _, err := ParseBranchName(name); err != nil {
129+ return false
130+ }
131+ for _, prefix := range p.branchPrefixes {
132+ if strings.HasPrefix(name, prefix) {
133+ return true
134+ }
135+ }
136+ return false
137+}
138+
108 // ExpiresAt returns the expiry of a stored token; zero means none.139 // ExpiresAt returns the expiry of a stored token; zero means none.
109 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }140 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
110 141
M internal/domain/token.go +49 -0
@@ -7,6 +7,7 @@ import (
77 "encoding/hex"
88 "fmt"
99 "slices"
10+ "strings"
1011 "time"
1112 )
1213
@@ -80,6 +81,10 @@ type Token struct {
8081 Scopes []Scope
8182 // Repos limits the token to these repositories; empty means every one.
8283 Repos []RepoName
84+ // BranchPrefixes limits the branches the token may write to those under
85+ // one of these prefixes (each ends with "/"); empty means every branch.
86+ // A limited token cannot write tags or other refs, nor create repositories.
87+ BranchPrefixes []string
8388 // ExpiresAt is the zero time for a token that never expires.
8489 ExpiresAt time.Time
8590 CreatedAt time.Time
@@ -90,3 +95,47 @@ type Token struct {
9095 func (t Token) Expired(now time.Time) bool {
9196 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
9297 }
98+
99+const (
100+ // MaxBranchPrefixLen is the longest branch prefix, in bytes.
101+ MaxBranchPrefixLen = 100
102+ // MaxBranchPrefixes is the most branch prefixes of a token.
103+ MaxBranchPrefixes = 8
104+)
105+
106+// ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends
107+// with "/", the rest is a valid branch name, and it has at most
108+// MaxBranchPrefixLen bytes. Errors match ErrInvalidToken.
109+func ParseBranchPrefix(s string) (string, error) {
110+ if len(s) > MaxBranchPrefixLen {
111+ return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen)
112+ }
113+ name, ok := strings.CutSuffix(s, "/")
114+ if !ok {
115+ return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s)
116+ }
117+ if _, err := ParseBranchName(name); err != nil {
118+ return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err)
119+ }
120+ return s, nil
121+}
122+
123+// ParseBranchPrefixes validates a list of branch prefixes: at most
124+// MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match
125+// ErrInvalidToken. The result is nil for an empty list.
126+func ParseBranchPrefixes(in []string) ([]string, error) {
127+ var out []string
128+ for _, s := range in {
129+ p, err := ParseBranchPrefix(s)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if !slices.Contains(out, p) {
134+ out = append(out, p)
135+ }
136+ }
137+ if len(out) > MaxBranchPrefixes {
138+ return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes)
139+ }
140+ return out, nil
141+}
@@ -7,6 +7,7 @@ import (
7 "encoding/hex"7 "encoding/hex"
8 "fmt"8 "fmt"
9 "slices"9 "slices"
10+ "strings"
10 "time"11 "time"
11 )12 )
12 13
@@ -80,6 +81,10 @@ type Token struct {
80 Scopes []Scope81 Scopes []Scope
81 // Repos limits the token to these repositories; empty means every one.82 // Repos limits the token to these repositories; empty means every one.
82 Repos []RepoName83 Repos []RepoName
84+ // BranchPrefixes limits the branches the token may write to those under
85+ // one of these prefixes (each ends with "/"); empty means every branch.
86+ // A limited token cannot write tags or other refs, nor create repositories.
87+ BranchPrefixes []string
83 // ExpiresAt is the zero time for a token that never expires.88 // ExpiresAt is the zero time for a token that never expires.
84 ExpiresAt time.Time89 ExpiresAt time.Time
85 CreatedAt time.Time90 CreatedAt time.Time
@@ -90,3 +95,47 @@ type Token struct {
90 func (t Token) Expired(now time.Time) bool {95 func (t Token) Expired(now time.Time) bool {
91 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)96 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
92 }97 }
98+
99+const (
100+ // MaxBranchPrefixLen is the longest branch prefix, in bytes.
101+ MaxBranchPrefixLen = 100
102+ // MaxBranchPrefixes is the most branch prefixes of a token.
103+ MaxBranchPrefixes = 8
104+)
105+
106+// ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends
107+// with "/", the rest is a valid branch name, and it has at most
108+// MaxBranchPrefixLen bytes. Errors match ErrInvalidToken.
109+func ParseBranchPrefix(s string) (string, error) {
110+ if len(s) > MaxBranchPrefixLen {
111+ return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen)
112+ }
113+ name, ok := strings.CutSuffix(s, "/")
114+ if !ok {
115+ return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s)
116+ }
117+ if _, err := ParseBranchName(name); err != nil {
118+ return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err)
119+ }
120+ return s, nil
121+}
122+
123+// ParseBranchPrefixes validates a list of branch prefixes: at most
124+// MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match
125+// ErrInvalidToken. The result is nil for an empty list.
126+func ParseBranchPrefixes(in []string) ([]string, error) {
127+ var out []string
128+ for _, s := range in {
129+ p, err := ParseBranchPrefix(s)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if !slices.Contains(out, p) {
134+ out = append(out, p)
135+ }
136+ }
137+ if len(out) > MaxBranchPrefixes {
138+ return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes)
139+ }
140+ return out, nil
141+}
M internal/domain/token_test.go +98 -0
@@ -3,6 +3,7 @@
33 package domain_test
44
55 import (
6+ "strings"
67 "testing"
78 "time"
89
@@ -111,3 +112,100 @@ func TestHasOwnerRights(t *testing.T) {
111112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
112113 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
113114 }
115+
116+func TestParseBranchPrefix(t *testing.T) {
117+ t.Parallel()
118+ for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} {
119+ got, err := domain.ParseBranchPrefix(ok)
120+ require.NoError(t, err, ok)
121+ assert.Equal(t, ok, got)
122+ }
123+ for _, bad := range []string{
124+ "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/",
125+ "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/",
126+ "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/",
127+ } {
128+ _, err := domain.ParseBranchPrefix(bad)
129+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
130+ }
131+}
132+
133+func TestParseBranchPrefixes(t *testing.T) {
134+ t.Parallel()
135+ got, err := domain.ParseBranchPrefixes(nil)
136+ require.NoError(t, err)
137+ assert.Empty(t, got)
138+
139+ got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"})
140+ require.NoError(t, err)
141+ assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept")
142+
143+ eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"}
144+ got, err = domain.ParseBranchPrefixes(eight)
145+ require.NoError(t, err)
146+ assert.Len(t, got, 8)
147+ _, err = domain.ParseBranchPrefixes(append(eight, "i/"))
148+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
149+ got, err = domain.ParseBranchPrefixes(append(eight, "a/"))
150+ require.NoError(t, err, "duplicates do not count")
151+ assert.Len(t, got, 8)
152+
153+ _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"})
154+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
155+}
156+
157+func TestAllowsRef(t *testing.T) {
158+ t.Parallel()
159+ limited := domain.TokenPrincipal(domain.Token{
160+ Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"},
161+ })
162+ for ref, want := range map[string]bool{
163+ "refs/heads/agent/claude/x": true,
164+ "refs/heads/agent/claude/a/b": true,
165+ "refs/heads/bot/y": true,
166+ "refs/heads/agent/claude": false,
167+ "refs/heads/agent/claude/": false,
168+ "refs/heads/agent/claudex/y": false,
169+ "refs/heads/main": false,
170+ "refs/heads/agent/other/x": false,
171+ "refs/heads/other/agent/claude/x": false,
172+ "refs/tags/v1": false,
173+ "refs/tags/agent/claude/x": false,
174+ "refs/notes/commits": false,
175+ "refs/remotes/agent/claude/x": false,
176+ "refs/changes/agent/claude/x": false,
177+ "agent/claude/x": false,
178+ "heads/agent/claude/x": false,
179+ "HEAD": false,
180+ "": false,
181+ "refs/heads/agent/claude/../main": false,
182+ "refs/heads/agent/claude/x.lock": false,
183+ "refs/heads/agent/claude/a b": false,
184+ "refs/heads//agent/claude/x": false,
185+ } {
186+ assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref)
187+ }
188+
189+ // Without prefixes every ref is allowed.
190+ unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}})
191+ assert.True(t, unlimited.AllowsRef("refs/tags/v1"))
192+ assert.True(t, unlimited.AllowsRef("refs/heads/main"))
193+ assert.True(t, domain.Root().AllowsRef("refs/tags/v1"))
194+ assert.True(t, domain.Owner().AllowsRef("refs/heads/main"))
195+}
196+
197+func TestBranchPrefixesOfPrincipal(t *testing.T) {
198+ t.Parallel()
199+ tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}}
200+ p := domain.TokenPrincipal(tok)
201+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
202+ assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner")
203+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
204+
205+ // The principal owns copies.
206+ tok.BranchPrefixes[0] = "b/"
207+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
208+ p.BranchPrefixes()[0] = "c/"
209+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
210+ assert.Empty(t, domain.Root().BranchPrefixes())
211+}
@@ -3,6 +3,7 @@
3 package domain_test3 package domain_test
4 4
5 import (5 import (
6+ "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -111,3 +112,100 @@ func TestHasOwnerRights(t *testing.T) {
111 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")113 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
113 }114 }
115+
116+func TestParseBranchPrefix(t *testing.T) {
117+ t.Parallel()
118+ for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} {
119+ got, err := domain.ParseBranchPrefix(ok)
120+ require.NoError(t, err, ok)
121+ assert.Equal(t, ok, got)
122+ }
123+ for _, bad := range []string{
124+ "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/",
125+ "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/",
126+ "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/",
127+ } {
128+ _, err := domain.ParseBranchPrefix(bad)
129+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
130+ }
131+}
132+
133+func TestParseBranchPrefixes(t *testing.T) {
134+ t.Parallel()
135+ got, err := domain.ParseBranchPrefixes(nil)
136+ require.NoError(t, err)
137+ assert.Empty(t, got)
138+
139+ got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"})
140+ require.NoError(t, err)
141+ assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept")
142+
143+ eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"}
144+ got, err = domain.ParseBranchPrefixes(eight)
145+ require.NoError(t, err)
146+ assert.Len(t, got, 8)
147+ _, err = domain.ParseBranchPrefixes(append(eight, "i/"))
148+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
149+ got, err = domain.ParseBranchPrefixes(append(eight, "a/"))
150+ require.NoError(t, err, "duplicates do not count")
151+ assert.Len(t, got, 8)
152+
153+ _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"})
154+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
155+}
156+
157+func TestAllowsRef(t *testing.T) {
158+ t.Parallel()
159+ limited := domain.TokenPrincipal(domain.Token{
160+ Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"},
161+ })
162+ for ref, want := range map[string]bool{
163+ "refs/heads/agent/claude/x": true,
164+ "refs/heads/agent/claude/a/b": true,
165+ "refs/heads/bot/y": true,
166+ "refs/heads/agent/claude": false,
167+ "refs/heads/agent/claude/": false,
168+ "refs/heads/agent/claudex/y": false,
169+ "refs/heads/main": false,
170+ "refs/heads/agent/other/x": false,
171+ "refs/heads/other/agent/claude/x": false,
172+ "refs/tags/v1": false,
173+ "refs/tags/agent/claude/x": false,
174+ "refs/notes/commits": false,
175+ "refs/remotes/agent/claude/x": false,
176+ "refs/changes/agent/claude/x": false,
177+ "agent/claude/x": false,
178+ "heads/agent/claude/x": false,
179+ "HEAD": false,
180+ "": false,
181+ "refs/heads/agent/claude/../main": false,
182+ "refs/heads/agent/claude/x.lock": false,
183+ "refs/heads/agent/claude/a b": false,
184+ "refs/heads//agent/claude/x": false,
185+ } {
186+ assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref)
187+ }
188+
189+ // Without prefixes every ref is allowed.
190+ unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}})
191+ assert.True(t, unlimited.AllowsRef("refs/tags/v1"))
192+ assert.True(t, unlimited.AllowsRef("refs/heads/main"))
193+ assert.True(t, domain.Root().AllowsRef("refs/tags/v1"))
194+ assert.True(t, domain.Owner().AllowsRef("refs/heads/main"))
195+}
196+
197+func TestBranchPrefixesOfPrincipal(t *testing.T) {
198+ t.Parallel()
199+ tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}}
200+ p := domain.TokenPrincipal(tok)
201+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
202+ assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner")
203+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
204+
205+ // The principal owns copies.
206+ tok.BranchPrefixes[0] = "b/"
207+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
208+ p.BranchPrefixes()[0] = "c/"
209+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
210+ assert.Empty(t, domain.Root().BranchPrefixes())
211+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close