detached 10270db

feat(api): expose token branch prefixes in the RPC and the CLI

Adds the token.branch_prefixes feature, `ermite token create --branch-prefix`
and a branches column / branch_prefixes JSON field in `token list`. The CLI
refuses to print the secret of a token that came back less limited than asked
(a server that predates the field).
commit
10270db20ca25e4b35100a7affc78a5b7c548f13
author
brice · · 2026-10-12 14:58 UTC
committer
brice · · 2026-10-12 15:01 UTC
parent
616a749
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +199 -42
M internal/adapters/client/client_test.go +2 -1
@@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146+ client.FeatureTokenBranchPrefixes,
146147 }))
147148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148149 }
@@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146+ client.FeatureTokenBranchPrefixes,
146 }))147 }))
147 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148 }149 }
M internal/adapters/client/features.go +6 -1
@@ -22,11 +22,16 @@ const (
2222 FeatureSigningKey = "signing_key"
2323 // FeatureSession is SessionService (`ermite web login`, `web logout`).
2424 FeatureSession = "session"
25+ // FeatureTokenBranchPrefixes is Token.branch_prefixes and
26+ // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`).
27+ // A server without it ignores the field: `token create` checks the
28+ // token it gets back.
29+ FeatureTokenBranchPrefixes = "token.branch_prefixes"
2530 )
2631
2732 // UsedFeatures lists the features the CLI uses, in the order they are reported.
2833 func UsedFeatures() []string {
29- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}
34+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes}
3035 }
3136
3237 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -22,11 +22,16 @@ const (
22 FeatureSigningKey = "signing_key"22 FeatureSigningKey = "signing_key"
23 // FeatureSession is SessionService (`ermite web login`, `web logout`).23 // FeatureSession is SessionService (`ermite web login`, `web logout`).
24 FeatureSession = "session"24 FeatureSession = "session"
25+ // FeatureTokenBranchPrefixes is Token.branch_prefixes and
26+ // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`).
27+ // A server without it ignores the field: `token create` checks the
28+ // token it gets back.
29+ FeatureTokenBranchPrefixes = "token.branch_prefixes"
25 )30 )
26 31
27 // UsedFeatures lists the features the CLI uses, in the order they are reported.32 // UsedFeatures lists the features the CLI uses, in the order they are reported.
28 func UsedFeatures() []string {33 func UsedFeatures() []string {
29- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}34+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes}
30 }35 }
31 36
32 // MissingFeatures returns the features the CLI uses that have is lacking.37 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/rpc/api.go +1 -1
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
2626
2727 // Features are the optional capabilities of the contract that this server
2828 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}
3030
3131 // Config configures the API.
3232 type Config struct {
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
26 26
27 // Features are the optional capabilities of the contract that this server27 // Features are the optional capabilities of the contract that this server
28 // implements, as GetVersion lists them.28 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}
30 30
31 // Config configures the API.31 // Config configures the API.
32 type Config struct {32 type Config struct {
M internal/adapters/rpc/convert.go +2 -0
@@ -4,6 +4,7 @@ package rpc
44
55 import (
66 "fmt"
7+ "slices"
78 "time"
89
910 "google.golang.org/protobuf/types/known/timestamppb"
@@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token {
155156 for _, r := range t.Repos {
156157 out.Repos = append(out.Repos, r.String())
157158 }
159+ out.BranchPrefixes = slices.Clone(t.BranchPrefixes)
158160 return out
159161 }
160162
@@ -4,6 +4,7 @@ package rpc
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "slices"
7 "time"8 "time"
8 9
9 "google.golang.org/protobuf/types/known/timestamppb"10 "google.golang.org/protobuf/types/known/timestamppb"
@@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token {
155 for _, r := range t.Repos {156 for _, r := range t.Repos {
156 out.Repos = append(out.Repos, r.String())157 out.Repos = append(out.Repos, r.String())
157 }158 }
159+ out.BranchPrefixes = slices.Clone(t.BranchPrefixes)
158 return out160 return out
159 }161 }
160 162
M internal/adapters/rpc/token.go +1 -0
@@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.
3434 }
3535 params.Repos = append(params.Repos, name)
3636 }
37+ params.BranchPrefixes = req.Msg.GetBranchPrefixes()
3738 created, err := s.tokens.Create(ctx, caller(ctx), params)
3839 if err != nil {
3940 return nil, connectError(ctx, err)
@@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.
34 }34 }
35 params.Repos = append(params.Repos, name)35 params.Repos = append(params.Repos, name)
36 }36 }
37+ params.BranchPrefixes = req.Msg.GetBranchPrefixes()
37 created, err := s.tokens.Create(ctx, caller(ctx), params)38 created, err := s.tokens.Create(ctx, caller(ctx), params)
38 if err != nil {39 if err != nil {
39 return nil, connectError(ctx, err)40 return nil, connectError(ctx, err)
M internal/adapters/rpc/token_test.go +31 -0
@@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) {
6464 require.NoError(t, err)
6565 }
6666
67+func TestTokenServiceBranchPrefixes(t *testing.T) {
68+ t.Parallel()
69+ tokens := mocks.NewTokenService(t)
70+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool {
71+ return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes)
72+ })).Return(ports.CreatedToken{
73+ Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}},
74+ Secret: "s",
75+ }, nil).Once()
76+ resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
77+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"},
78+ }))
79+ require.NoError(t, err)
80+ assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes())
81+
82+ // A malformed prefix is the use case's invalid_token: invalid_argument.
83+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once()
84+ _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
85+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"},
86+ }))
87+ requireCode(t, connect.CodeInvalidArgument, err)
88+
89+ // Listing returns them too; a token without any has none.
90+ tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, "").
91+ Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once()
92+ list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{}))
93+ require.NoError(t, err)
94+ assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes())
95+ assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes())
96+}
97+
6798 func TestTokenServiceRevokeToken(t *testing.T) {
6899 t.Parallel()
69100 tests := []struct {
@@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) {
64 require.NoError(t, err)64 require.NoError(t, err)
65 }65 }
66 66
67+func TestTokenServiceBranchPrefixes(t *testing.T) {
68+ t.Parallel()
69+ tokens := mocks.NewTokenService(t)
70+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool {
71+ return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes)
72+ })).Return(ports.CreatedToken{
73+ Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}},
74+ Secret: "s",
75+ }, nil).Once()
76+ resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
77+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"},
78+ }))
79+ require.NoError(t, err)
80+ assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes())
81+
82+ // A malformed prefix is the use case's invalid_token: invalid_argument.
83+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once()
84+ _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
85+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"},
86+ }))
87+ requireCode(t, connect.CodeInvalidArgument, err)
88+
89+ // Listing returns them too; a token without any has none.
90+ tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, "").
91+ Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once()
92+ list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{}))
93+ require.NoError(t, err)
94+ assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes())
95+ assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes())
96+}
97+
67 func TestTokenServiceRevokeToken(t *testing.T) {98 func TestTokenServiceRevokeToken(t *testing.T) {
68 t.Parallel()99 t.Parallel()
69 tests := []struct {100 tests := []struct {
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +3 -3
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)
327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, ver.Server.Features)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 require.Equal(t, 0, code, stderr)
@@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
336336 stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
337337 require.Equal(t, 0, code, stderr)
338338 res = result{stdout: stdout}
339- assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
339+ assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
340340 tmp := decode[tokenOut](t, res.stdout)
341341 assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
342342 assert.Equal(t, []string{"proj"}, tmp.Repos)
@@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
366366 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
367367 assert.NotContains(t, res.stdout, `"secret"`)
368368 res = c.cli(t, secret, "token", "list").ok(t)
369- assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout)
369+ assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout)
370370 assert.Contains(t, res.stdout, tmp.ID)
371371
372372 stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324 ver := decode[versionOut](t, res.stdout)324 ver := decode[versionOut](t, res.stdout)
325 assert.Equal(t, c.url, ver.Server.URL)325 assert.Equal(t, c.url, ver.Server.URL)
326 assert.NotEmpty(t, ver.Server.Version)326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes"}, ver.Server.Features)
328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329 stdout, stderr, code = c.bin(t, secret, "version")329 stdout, stderr, code = c.bin(t, secret, "version")
330 require.Equal(t, 0, code, stderr)330 require.Equal(t, 0, code, stderr)
@@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
336 stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")336 stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
337 require.Equal(t, 0, code, stderr)337 require.Equal(t, 0, code, stderr)
338 res = result{stdout: stdout}338 res = result{stdout: stdout}
339- assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))339+ assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
340 tmp := decode[tokenOut](t, res.stdout)340 tmp := decode[tokenOut](t, res.stdout)
341 assert.Equal(t, []string{"repo:read"}, tmp.Scopes)341 assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
342 assert.Equal(t, []string{"proj"}, tmp.Repos)342 assert.Equal(t, []string{"proj"}, tmp.Repos)
@@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
366 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)366 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
367 assert.NotContains(t, res.stdout, `"secret"`)367 assert.NotContains(t, res.stdout, `"secret"`)
368 res = c.cli(t, secret, "token", "list").ok(t)368 res = c.cli(t, secret, "token", "list").ok(t)
369- assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout)369+ assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout)
370 assert.Contains(t, res.stdout, tmp.ID)370 assert.Contains(t, res.stdout, tmp.ID)
371 371
372 stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)372 stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
M internal/app/cli_token.go +60 -18
@@ -21,13 +21,15 @@ import (
2121 // jsonToken is the stable JSON shape of a stored token. The secret is only
2222 // present in the output of `token create`.
2323 type jsonToken struct {
24- ID string `json:"id"`
25- Label string `json:"label"`
26- Scopes []string `json:"scopes"`
27- Repos []string `json:"repos"`
28- ExpiresAt *string `json:"expires_at"`
29- CreatedAt *string `json:"created_at"`
30- LastUsedAt *string `json:"last_used_at"`
24+ ID string `json:"id"`
25+ Label string `json:"label"`
26+ Scopes []string `json:"scopes"`
27+ Repos []string `json:"repos"`
28+ // BranchPrefixes limits the branches the token may write; empty means all.
29+ BranchPrefixes []string `json:"branch_prefixes"`
30+ ExpiresAt *string `json:"expires_at"`
31+ CreatedAt *string `json:"created_at"`
32+ LastUsedAt *string `json:"last_used_at"`
3133 }
3234
3335 type jsonCreatedToken struct {
@@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken {
4446 if repos == nil {
4547 repos = []string{}
4648 }
49+ prefixes := t.GetBranchPrefixes()
50+ if prefixes == nil {
51+ prefixes = []string{}
52+ }
4753 return jsonToken{
48- ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos,
54+ ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes,
4955 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
5056 }
5157 }
5258
5359 const jsonTokenHelp = `
5460 JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
55-repos (array; empty means every repository), expires_at, created_at and last_used_at
56-(RFC 3339 UTC, null when unset or never used).`
61+repos (array; empty means every repository), branch_prefixes (array; empty means every branch),
62+expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).`
5763
5864 func newTokenCmd(env *cliEnv) *cobra.Command {
5965 token := &cobra.Command{
@@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command {
6773
6874 func newTokenCreateCmd(env *cliEnv) *cobra.Command {
6975 var (
70- preset string
71- scopes []string
72- repos []string
73- expires string
76+ preset string
77+ scopes []string
78+ repos []string
79+ prefixes []string
80+ expires string
7481 )
7582 cmd := &cobra.Command{
7683 Use: "create <label>",
@@ -84,6 +91,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
8491 " ci repo:read\n" +
8592 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
8693 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
94+ "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
95+ "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
96+ "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
97+ "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
98+ "To give an AI agent its own space:\n" +
99+ " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
87100 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
88101 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
89102 Args: cobra.ExactArgs(1),
@@ -91,6 +104,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
91104 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
92105 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
93106 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
107+ cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)")
94108 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
95109 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
96110 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
@@ -103,7 +117,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
103117 })
104118 asJSON := addJSONFlag(cmd)
105119 cmd.RunE = func(cmd *cobra.Command, args []string) error {
106- req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now())
120+ req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
107121 if err != nil {
108122 return err
109123 }
@@ -115,13 +129,27 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
115129 if err != nil {
116130 return c.Describe(err)
117131 }
132+ if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
133+ return err
134+ }
118135 return printCreatedToken(cmd, res.Msg, *asJSON)
119136 }
120137 return cmd
121138 }
122139
140+// checkBranchPrefixes refuses to hand out a token that is less limited than
141+// asked for. A server that predates branch prefixes ignores the field and
142+// creates an unlimited token: the secret is then not printed.
143+func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
144+ if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
145+ return nil
146+ }
147+ return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
148+ "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
149+}
150+
123151 // buildCreateToken maps the flags of `token create` to the request.
124-func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
152+func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
125153 label = strings.TrimSpace(label)
126154 if label == "" {
127155 return nil, errors.New("the label must not be empty")
@@ -136,6 +164,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi
136164 req.Repos = append(req.Repos, r)
137165 }
138166 }
167+ for _, bp := range prefixFlags {
168+ if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
169+ req.BranchPrefixes = append(req.BranchPrefixes, bp)
170+ }
171+ }
139172 if expires != "" {
140173 at, err := client.ParseExpiry(expires, now)
141174 if err != nil {
@@ -155,10 +188,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS
155188 if len(t.GetRepos()) > 0 {
156189 repos = strings.Join(t.GetRepos(), ", ")
157190 }
191+ branches := "all"
192+ if len(t.GetBranchPrefixes()) > 0 {
193+ branches = strings.Join(t.GetBranchPrefixes(), ", ")
194+ }
158195 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
159196 _ = fields(cmd.ErrOrStderr(), [][2]string{
160197 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
161198 {" repos", repos},
199+ {" branches", branches},
162200 {" expires", shortTime(t.GetExpiresAt(), "never")},
163201 })
164202 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
@@ -210,12 +248,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
210248 if len(t.GetRepos()) > 0 {
211249 repos = strings.Join(t.GetRepos(), ",")
212250 }
251+ branches := "all"
252+ if len(t.GetBranchPrefixes()) > 0 {
253+ branches = strings.Join(t.GetBranchPrefixes(), ",")
254+ }
213255 rows = append(rows, []string{
214- t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos,
256+ t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
215257 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
216258 })
217259 }
218- return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows)
260+ return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
219261 }
220262 return cmd
221263 }
@@ -21,13 +21,15 @@ import (
21 // jsonToken is the stable JSON shape of a stored token. The secret is only21 // jsonToken is the stable JSON shape of a stored token. The secret is only
22 // present in the output of `token create`.22 // present in the output of `token create`.
23 type jsonToken struct {23 type jsonToken struct {
24- ID string `json:"id"`24+ ID string `json:"id"`
25- Label string `json:"label"`25+ Label string `json:"label"`
26- Scopes []string `json:"scopes"`26+ Scopes []string `json:"scopes"`
27- Repos []string `json:"repos"`27+ Repos []string `json:"repos"`
28- ExpiresAt *string `json:"expires_at"`28+ // BranchPrefixes limits the branches the token may write; empty means all.
29- CreatedAt *string `json:"created_at"`29+ BranchPrefixes []string `json:"branch_prefixes"`
30- LastUsedAt *string `json:"last_used_at"`30+ ExpiresAt *string `json:"expires_at"`
31+ CreatedAt *string `json:"created_at"`
32+ LastUsedAt *string `json:"last_used_at"`
31 }33 }
32 34
33 type jsonCreatedToken struct {35 type jsonCreatedToken struct {
@@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken {
44 if repos == nil {46 if repos == nil {
45 repos = []string{}47 repos = []string{}
46 }48 }
49+ prefixes := t.GetBranchPrefixes()
50+ if prefixes == nil {
51+ prefixes = []string{}
52+ }
47 return jsonToken{53 return jsonToken{
48- ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos,54+ ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes,
49 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),55 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
50 }56 }
51 }57 }
52 58
53 const jsonTokenHelp = `59 const jsonTokenHelp = `
54 JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),60 JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
55-repos (array; empty means every repository), expires_at, created_at and last_used_at61+repos (array; empty means every repository), branch_prefixes (array; empty means every branch),
56-(RFC 3339 UTC, null when unset or never used).`62+expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).`
57 63
58 func newTokenCmd(env *cliEnv) *cobra.Command {64 func newTokenCmd(env *cliEnv) *cobra.Command {
59 token := &cobra.Command{65 token := &cobra.Command{
@@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command {
67 73
68 func newTokenCreateCmd(env *cliEnv) *cobra.Command {74 func newTokenCreateCmd(env *cliEnv) *cobra.Command {
69 var (75 var (
70- preset string76+ preset string
71- scopes []string77+ scopes []string
72- repos []string78+ repos []string
73- expires string79+ prefixes []string
80+ expires string
74 )81 )
75 cmd := &cobra.Command{82 cmd := &cobra.Command{
76 Use: "create <label>",83 Use: "create <label>",
@@ -84,6 +91,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
84 " ci repo:read\n" +91 " ci repo:read\n" +
85 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +92 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
86 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +93 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
94+ "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
95+ "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
96+ "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
97+ "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
98+ "To give an AI agent its own space:\n" +
99+ " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
87 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +100 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
88 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,101 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
89 Args: cobra.ExactArgs(1),102 Args: cobra.ExactArgs(1),
@@ -91,6 +104,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
91 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))104 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
92 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")105 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
93 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")106 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
107+ cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)")
94 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")108 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
95 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {109 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
96 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp110 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
@@ -103,7 +117,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
103 })117 })
104 asJSON := addJSONFlag(cmd)118 asJSON := addJSONFlag(cmd)
105 cmd.RunE = func(cmd *cobra.Command, args []string) error {119 cmd.RunE = func(cmd *cobra.Command, args []string) error {
106- req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now())120+ req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
107 if err != nil {121 if err != nil {
108 return err122 return err
109 }123 }
@@ -115,13 +129,27 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
115 if err != nil {129 if err != nil {
116 return c.Describe(err)130 return c.Describe(err)
117 }131 }
132+ if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
133+ return err
134+ }
118 return printCreatedToken(cmd, res.Msg, *asJSON)135 return printCreatedToken(cmd, res.Msg, *asJSON)
119 }136 }
120 return cmd137 return cmd
121 }138 }
122 139
140+// checkBranchPrefixes refuses to hand out a token that is less limited than
141+// asked for. A server that predates branch prefixes ignores the field and
142+// creates an unlimited token: the secret is then not printed.
143+func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
144+ if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
145+ return nil
146+ }
147+ return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
148+ "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
149+}
150+
123 // buildCreateToken maps the flags of `token create` to the request.151 // buildCreateToken maps the flags of `token create` to the request.
124-func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {152+func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
125 label = strings.TrimSpace(label)153 label = strings.TrimSpace(label)
126 if label == "" {154 if label == "" {
127 return nil, errors.New("the label must not be empty")155 return nil, errors.New("the label must not be empty")
@@ -136,6 +164,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi
136 req.Repos = append(req.Repos, r)164 req.Repos = append(req.Repos, r)
137 }165 }
138 }166 }
167+ for _, bp := range prefixFlags {
168+ if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
169+ req.BranchPrefixes = append(req.BranchPrefixes, bp)
170+ }
171+ }
139 if expires != "" {172 if expires != "" {
140 at, err := client.ParseExpiry(expires, now)173 at, err := client.ParseExpiry(expires, now)
141 if err != nil {174 if err != nil {
@@ -155,10 +188,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS
155 if len(t.GetRepos()) > 0 {188 if len(t.GetRepos()) > 0 {
156 repos = strings.Join(t.GetRepos(), ", ")189 repos = strings.Join(t.GetRepos(), ", ")
157 }190 }
191+ branches := "all"
192+ if len(t.GetBranchPrefixes()) > 0 {
193+ branches = strings.Join(t.GetBranchPrefixes(), ", ")
194+ }
158 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))195 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
159 _ = fields(cmd.ErrOrStderr(), [][2]string{196 _ = fields(cmd.ErrOrStderr(), [][2]string{
160 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},197 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
161 {" repos", repos},198 {" repos", repos},
199+ {" branches", branches},
162 {" expires", shortTime(t.GetExpiresAt(), "never")},200 {" expires", shortTime(t.GetExpiresAt(), "never")},
163 })201 })
164 noticef(cmd, "the secret below is shown once and cannot be retrieved later")202 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
@@ -210,12 +248,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
210 if len(t.GetRepos()) > 0 {248 if len(t.GetRepos()) > 0 {
211 repos = strings.Join(t.GetRepos(), ",")249 repos = strings.Join(t.GetRepos(), ",")
212 }250 }
251+ branches := "all"
252+ if len(t.GetBranchPrefixes()) > 0 {
253+ branches = strings.Join(t.GetBranchPrefixes(), ",")
254+ }
213 rows = append(rows, []string{255 rows = append(rows, []string{
214- t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos,256+ t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
215 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),257 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
216 })258 })
217 }259 }
218- return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows)260+ return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
219 }261 }
220 return cmd262 return cmd
221 }263 }
M internal/app/cli_token_test.go +80 -6
@@ -42,6 +42,80 @@ func TestTokenCreateMapsFlags(t *testing.T) {
4242 assert.NotContains(t, res.stderr, "SECRETVALUE")
4343 }
4444
45+func TestTokenCreateBranchPrefixes(t *testing.T) {
46+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
47+ return &ermite.CreateTokenResponse{
48+ Token: &ermite.Token{
49+ Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(),
50+ CreatedAt: ts("2026-10-10T12:00:00Z"),
51+ },
52+ Secret: "ermite_S",
53+ }, nil
54+ }
55+
56+ t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) {
57+ f := &fakeAPI{createToken: echo}
58+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha",
59+ "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t)
60+ _, reqs := f.snapshot()
61+ assert.Equal(t, []string{"agent/claude/", "bot/"}, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
62+ assert.Equal(t, "ermite_S\n", res.stdout)
63+ assert.Contains(t, res.stderr, "branches")
64+ assert.Contains(t, res.stderr, "agent/claude/, bot/")
65+ })
66+
67+ t.Run("without the flag the token shows every branch", func(t *testing.T) {
68+ f := &fakeAPI{createToken: echo}
69+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
70+ _, reqs := f.snapshot()
71+ assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
72+ assert.Regexp(t, `branches\s+all`, res.stderr)
73+ })
74+
75+ t.Run("json", func(t *testing.T) {
76+ f := &fakeAPI{createToken: echo}
77+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t)
78+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
79+ })
80+
81+ t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) {
82+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
83+ return &ermite.CreateTokenResponse{
84+ Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
85+ Secret: "ermite_UNLIMITED",
86+ }, nil
87+ }}
88+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
89+ require.Error(t, res.err)
90+ assert.Contains(t, res.err.Error(), "without the requested branch prefixes")
91+ assert.Contains(t, res.err.Error(), "ermite token revoke tok9")
92+ assert.NotContains(t, res.stdout, "UNLIMITED")
93+ assert.NotContains(t, res.stderr, "UNLIMITED")
94+ assert.NotContains(t, res.err.Error(), "UNLIMITED")
95+ })
96+}
97+
98+func TestTokenListBranchPrefixes(t *testing.T) {
99+ f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
100+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
101+ {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")},
102+ {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
103+ }}, nil
104+ }}
105+ url := f.serve(t)
106+ res := runCLI(t, envFor(url), "", "token", "list").ok(t)
107+ assert.Equal(t, ""+
108+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
109+ "t2 claude repo:write all agent/claude/,bot/ never never\n"+
110+ "t1 ci repo:read all all never never\n", res.stdout)
111+
112+ res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
113+ assert.JSONEq(t, `[
114+ {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null},
115+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
116+ ]`, res.stdout)
117+}
118+
45119 func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
46120 for preset, want := range map[string][]ermite.Scope{
47121 "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
@@ -70,7 +144,7 @@ func TestTokenCreateJSON(t *testing.T) {
70144 }, nil
71145 }}
72146 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
73- assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
147+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
74148 assert.Empty(t, res.stderr)
75149 }
76150
@@ -106,14 +180,14 @@ func TestTokenList(t *testing.T) {
106180 url := f.serve(t)
107181 res := runCLI(t, envFor(url), "", "token", "list").ok(t)
108182 assert.Equal(t, ""+
109- "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+
110- "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+
111- "t1 ci repo:read all never never\n", res.stdout)
183+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
184+ "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+
185+ "t1 ci repo:read all all never never\n", res.stdout)
112186
113187 res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
114188 assert.JSONEq(t, `[
115- {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
116- {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
189+ {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
190+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
117191 ]`, res.stdout)
118192 assert.NotContains(t, res.stdout, "secret")
119193 }
@@ -42,6 +42,80 @@ func TestTokenCreateMapsFlags(t *testing.T) {
42 assert.NotContains(t, res.stderr, "SECRETVALUE")42 assert.NotContains(t, res.stderr, "SECRETVALUE")
43 }43 }
44 44
45+func TestTokenCreateBranchPrefixes(t *testing.T) {
46+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
47+ return &ermite.CreateTokenResponse{
48+ Token: &ermite.Token{
49+ Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(),
50+ CreatedAt: ts("2026-10-10T12:00:00Z"),
51+ },
52+ Secret: "ermite_S",
53+ }, nil
54+ }
55+
56+ t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) {
57+ f := &fakeAPI{createToken: echo}
58+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha",
59+ "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t)
60+ _, reqs := f.snapshot()
61+ assert.Equal(t, []string{"agent/claude/", "bot/"}, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
62+ assert.Equal(t, "ermite_S\n", res.stdout)
63+ assert.Contains(t, res.stderr, "branches")
64+ assert.Contains(t, res.stderr, "agent/claude/, bot/")
65+ })
66+
67+ t.Run("without the flag the token shows every branch", func(t *testing.T) {
68+ f := &fakeAPI{createToken: echo}
69+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
70+ _, reqs := f.snapshot()
71+ assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
72+ assert.Regexp(t, `branches\s+all`, res.stderr)
73+ })
74+
75+ t.Run("json", func(t *testing.T) {
76+ f := &fakeAPI{createToken: echo}
77+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t)
78+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
79+ })
80+
81+ t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) {
82+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
83+ return &ermite.CreateTokenResponse{
84+ Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
85+ Secret: "ermite_UNLIMITED",
86+ }, nil
87+ }}
88+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
89+ require.Error(t, res.err)
90+ assert.Contains(t, res.err.Error(), "without the requested branch prefixes")
91+ assert.Contains(t, res.err.Error(), "ermite token revoke tok9")
92+ assert.NotContains(t, res.stdout, "UNLIMITED")
93+ assert.NotContains(t, res.stderr, "UNLIMITED")
94+ assert.NotContains(t, res.err.Error(), "UNLIMITED")
95+ })
96+}
97+
98+func TestTokenListBranchPrefixes(t *testing.T) {
99+ f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
100+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
101+ {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")},
102+ {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
103+ }}, nil
104+ }}
105+ url := f.serve(t)
106+ res := runCLI(t, envFor(url), "", "token", "list").ok(t)
107+ assert.Equal(t, ""+
108+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
109+ "t2 claude repo:write all agent/claude/,bot/ never never\n"+
110+ "t1 ci repo:read all all never never\n", res.stdout)
111+
112+ res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
113+ assert.JSONEq(t, `[
114+ {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null},
115+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
116+ ]`, res.stdout)
117+}
118+
45 func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {119 func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
46 for preset, want := range map[string][]ermite.Scope{120 for preset, want := range map[string][]ermite.Scope{
47 "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},121 "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
@@ -70,7 +144,7 @@ func TestTokenCreateJSON(t *testing.T) {
70 }, nil144 }, nil
71 }}145 }}
72 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)146 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
73- assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)147+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
74 assert.Empty(t, res.stderr)148 assert.Empty(t, res.stderr)
75 }149 }
76 150
@@ -106,14 +180,14 @@ func TestTokenList(t *testing.T) {
106 url := f.serve(t)180 url := f.serve(t)
107 res := runCLI(t, envFor(url), "", "token", "list").ok(t)181 res := runCLI(t, envFor(url), "", "token", "list").ok(t)
108 assert.Equal(t, ""+182 assert.Equal(t, ""+
109- "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+183+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
110- "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+184+ "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+
111- "t1 ci repo:read all never never\n", res.stdout)185+ "t1 ci repo:read all all never never\n", res.stdout)
112 186
113 res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)187 res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
114 assert.JSONEq(t, `[188 assert.JSONEq(t, `[
115- {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},189+ {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
116- {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}190+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
117 ]`, res.stdout)191 ]`, res.stdout)
118 assert.NotContains(t, res.stdout, "secret")192 assert.NotContains(t, res.stdout, "secret")
119 }193 }
M internal/app/cli_version.go +7 -6
@@ -100,10 +100,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
100100
101101 // featureUse says what breaks without a feature, for the version warning.
102102 var featureUse = map[string]string{
103- client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104- client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105- client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106- client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107- client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108- client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
103+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105+ client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106+ client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107+ client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
109+ client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
109110 }
@@ -100,10 +100,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
100 100
101 // featureUse says what breaks without a feature, for the version warning.101 // featureUse says what breaks without a feature, for the version warning.
102 var featureUse = map[string]string{102 var featureUse = map[string]string{
103- client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",103+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104- client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",104+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105- client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",105+ client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106- client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",106+ client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107- client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",107+ client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108- client.FeatureSession: "`ermite web login` needs: the command will fail on this server",108+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
109+ client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
109 }110 }
M internal/app/cli_version_test.go +5 -5
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes"}
1818
1919 func versionFake(features ...string) *fakeAPI {
2020 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
3535 f := versionFake(allFeatures...)
3636 url := f.serve(t)
3737 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)
38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes\n", res.stdout)
3939 assert.Empty(t, res.stderr, "no warning when every feature is there")
4040
4141 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)
42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":[]}}`, res.stdout)
4343 }
4444
4545 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")
46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes")
4747 url := f.serve(t)
4848 res := runCLI(t, envFor(url), "", "version").ok(t)
4949 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5151 assert.NotContains(t, res.stderr, "repo.update")
5252
5353 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":["events.resume"]}}`, res.stdout)
5555 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5656
5757 f = versionFake()
@@ -14,7 +14,7 @@ import (
14 )14 )
15 15
16 // allFeatures are the features this client uses.16 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes"}
18 18
19 func versionFake(features ...string) *fakeAPI {19 func versionFake(features ...string) *fakeAPI {
20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
35 f := versionFake(allFeatures...)35 f := versionFake(allFeatures...)
36 url := f.serve(t)36 url := f.serve(t)
37 res := runCLI(t, envFor(url), "", "version").ok(t)37 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes\n", res.stdout)
39 assert.Empty(t, res.stderr, "no warning when every feature is there")39 assert.Empty(t, res.stderr, "no warning when every feature is there")
40 40
41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":[]}}`, res.stdout)
43 }43 }
44 44
45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes")
47 url := f.serve(t)47 url := f.serve(t)
48 res := runCLI(t, envFor(url), "", "version").ok(t)48 res := runCLI(t, envFor(url), "", "version").ok(t)
49 assert.Contains(t, res.stdout, "v0.4.0")49 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
51 assert.NotContains(t, res.stderr, "repo.update")51 assert.NotContains(t, res.stderr, "repo.update")
52 52
53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes"],"missing_features":["events.resume"]}}`, res.stdout)
55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
56 56
57 f = versionFake()57 f = versionFake()

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 2

Tags 45

↑↓ moveenter openesc close