detached 0ebe073

feat(cli): add the mirror commands and wire push mirrors into the forge

commit
0ebe0739de4dd09eed63be9e9298ea17fecb5ee6
author
brice · · 2026-10-20 06:01 UTC
committer
brice · · 2026-10-20 10:02 UTC
parent
86b5a04
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +478 -3
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", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, 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", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, 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 +1 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
A internal/app/cli_mirror.go +256 -0
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "strings"
9+
10+ "connectrpc.com/connect"
11+ "github.com/spf13/cobra"
12+ "google.golang.org/protobuf/encoding/protojson"
13+ "google.golang.org/protobuf/proto"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+)
17+
18+// mirrorJSON keeps the API's snake_case field names, including unset defaults.
19+func mirrorJSON(cmd *cobra.Command, msg proto.Message) error {
20+ b, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(msg)
21+ if err != nil {
22+ return err
23+ }
24+ _, err = fmt.Fprintln(cmd.OutOrStdout(), string(b))
25+ return err
26+}
27+
28+func newMirrorCmd(env *cliEnv) *cobra.Command {
29+ cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}
30+ key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}
31+ for _, rotate := range []bool{false, true} {
32+ name := "show"
33+ if rotate {
34+ name = "rotate"
35+ }
36+ sub := &cobra.Command{Use: name, Short: "Print the mirror authorized_keys line", Args: cobra.NoArgs,
37+ Long: "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe show into: gh ssh-key add - --title \"ermite mirror\". Rotation requires owner rights and takes effect immediately."}
38+ asJSON := addJSONFlag(sub)
39+ sub.RunE = func(cmd *cobra.Command, _ []string) error {
40+ c, err := env.client(cmd)
41+ if err != nil {
42+ return err
43+ }
44+ var k *ermite.MirrorKey
45+ if rotate {
46+ res, e := c.Mirrors().RotateMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateMirrorKeyRequest{}))
47+ if e != nil {
48+ return c.Describe(e)
49+ }
50+ k = res.Msg.GetKey()
51+ noticef(cmd, "remotes refuse pushes until this key is added")
52+ } else {
53+ res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))
54+ if e != nil {
55+ return c.Describe(e)
56+ }
57+ k = res.Msg.GetKey()
58+ }
59+ if *asJSON {
60+ return mirrorJSON(cmd, k)
61+ }
62+ _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))
63+ return err
64+ }
65+ key.AddCommand(sub)
66+ }
67+ cmd.AddCommand(key, newMirrorConfigCmd(env), newMirrorAddCmd(env), newMirrorStatusCmd(env), newMirrorSyncCmd(env))
68+ for _, name := range []string{"remove", "exclude", "include"} {
69+ sub := &cobra.Command{Use: name + " <repo>", Short: name + " a repository mirror", Args: cobra.ExactArgs(1)}
70+ sub.RunE = func(cmd *cobra.Command, args []string) error {
71+ c, err := env.client(cmd)
72+ if err != nil {
73+ return err
74+ }
75+ if name == "remove" {
76+ _, err = c.Mirrors().RemoveRepoMirror(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRepoMirrorRequest{Repo: args[0]}))
77+ } else {
78+ _, err = c.Mirrors().SetRepoMirrorExcluded(env.ctx(cmd), connect.NewRequest(&ermite.SetRepoMirrorExcludedRequest{Repo: args[0], Excluded: name == "exclude"}))
79+ }
80+ if err != nil {
81+ return c.Describe(err)
82+ }
83+ noticef(cmd, "%s: %s", name, clean(args[0]))
84+ return nil
85+ }
86+ cmd.AddCommand(sub)
87+ }
88+ return cmd
89+}
90+
91+func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
92+ var enabled, all bool
93+ var url, cron string
94+ cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Args: cobra.NoArgs}
95+ cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")
96+ cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")
97+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")
98+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches instead of only the default branch")
99+ asJSON := addJSONFlag(cmd)
100+ output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {
101+ if json {
102+ return mirrorJSON(cmd, g)
103+ }
104+ return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})
105+ }
106+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
107+ c, err := env.client(cmd)
108+ if err != nil {
109+ return err
110+ }
111+ req := &ermite.SetMirrorConfigRequest{}
112+ if cmd.Flags().Changed("enabled") {
113+ req.Enabled = &enabled
114+ }
115+ if cmd.Flags().Changed("url") {
116+ req.UrlTemplate = &url
117+ }
118+ if cmd.Flags().Changed("cron") {
119+ req.Cron = &cron
120+ }
121+ if cmd.Flags().Changed("all-branches") {
122+ req.AllBranches = &all
123+ }
124+ if req.Enabled == nil && req.UrlTemplate == nil && req.Cron == nil && req.AllBranches == nil {
125+ return fmt.Errorf("specify at least one config flag, or use `mirror config show`")
126+ }
127+ res, err := c.Mirrors().SetMirrorConfig(env.ctx(cmd), connect.NewRequest(req))
128+ if err != nil {
129+ return c.Describe(err)
130+ }
131+ return output(cmd, res.Msg.GetConfig(), *asJSON)
132+ }
133+ show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Args: cobra.NoArgs}
134+ showJSON := addJSONFlag(show)
135+ show.RunE = func(cmd *cobra.Command, _ []string) error {
136+ c, err := env.client(cmd)
137+ if err != nil {
138+ return err
139+ }
140+ res, err := c.Mirrors().GetMirrorConfig(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorConfigRequest{}))
141+ if err != nil {
142+ return c.Describe(err)
143+ }
144+ return output(cmd, res.Msg.GetConfig(), *showJSON)
145+ }
146+ cmd.AddCommand(show)
147+ return cmd
148+}
149+
150+func newMirrorAddCmd(env *cliEnv) *cobra.Command {
151+ var cron string
152+ var all bool
153+ cmd := &cobra.Command{Use: "add <repo> <url>", Short: "Set a repository mirror override", Args: cobra.ExactArgs(2)}
154+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (empty inherits global)")
155+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches (omitted inherits global)")
156+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
157+ c, err := env.client(cmd)
158+ if err != nil {
159+ return err
160+ }
161+ req := &ermite.SetRepoMirrorRequest{Repo: args[0], Url: args[1], Cron: cron}
162+ if cmd.Flags().Changed("all-branches") {
163+ req.AllBranches = &all
164+ }
165+ _, err = c.Mirrors().SetRepoMirror(env.ctx(cmd), connect.NewRequest(req))
166+ if err != nil {
167+ return c.Describe(err)
168+ }
169+ noticef(cmd, "mirror configured for %s", clean(args[0]))
170+ return nil
171+ }
172+ return cmd
173+}
174+
175+func mirrorStateName(s ermite.MirrorState) string {
176+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_STATE_"))
177+}
178+
179+func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
180+ cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Args: cobra.MaximumNArgs(1)}
181+ asJSON := addJSONFlag(cmd)
182+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
183+ c, err := env.client(cmd)
184+ if err != nil {
185+ return err
186+ }
187+ var statuses []*ermite.MirrorStatus
188+ if len(args) == 1 {
189+ res, err := c.Mirrors().GetMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorStatusRequest{Repo: args[0]}))
190+ if err != nil {
191+ return c.Describe(err)
192+ }
193+ if *asJSON {
194+ return mirrorJSON(cmd, res.Msg.GetStatus())
195+ }
196+ statuses = append(statuses, res.Msg.GetStatus())
197+ } else {
198+ res, err := c.Mirrors().ListMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.ListMirrorStatusRequest{}))
199+ if err != nil {
200+ return c.Describe(err)
201+ }
202+ if *asJSON {
203+ // Marshal each status with proto JSON, but retain the CLI list convention: an array.
204+ items := make([]string, 0, len(res.Msg.GetStatuses()))
205+ for _, s := range res.Msg.GetStatuses() {
206+ b, e := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(s)
207+ if e != nil {
208+ return e
209+ }
210+ items = append(items, string(b))
211+ }
212+ _, err = fmt.Fprintln(cmd.OutOrStdout(), "["+strings.Join(items, ",")+"]")
213+ return err
214+ }
215+ statuses = res.Msg.GetStatuses()
216+ }
217+ rows := make([][]string, 0, len(statuses))
218+ for _, s := range statuses {
219+ rows = append(rows, []string{s.GetTarget().GetRepo(), mirrorStateName(s.GetState()), s.GetReason(), shortTime(s.GetLastSuccessAt(), "-"), shortTime(s.GetNextRunAt(), "-"), strconv.Itoa(int(s.GetConsecutiveFailures()))})
220+ }
221+ return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "REASON", "LAST SUCCESS", "NEXT RUN", "FAILURES"}, rows)
222+ }
223+ return cmd
224+}
225+
226+func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
227+ var all, force bool
228+ cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
229+ if (all && len(args) == 0) || (!all && len(args) == 1) {
230+ return nil
231+ }
232+ return fmt.Errorf("specify exactly one repository or --all")
233+ }}
234+ cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors")
235+ cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push")
236+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
237+ c, err := env.client(cmd)
238+ if err != nil {
239+ return err
240+ }
241+ req := &ermite.SyncMirrorRequest{All: all, Force: force}
242+ if !all {
243+ req.Repo = args[0]
244+ }
245+ res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req))
246+ if err != nil {
247+ return c.Describe(err)
248+ }
249+ rows := make([][]string, 0, len(res.Msg.GetResults()))
250+ for _, r := range res.Msg.GetResults() {
251+ rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), r.GetStatus().GetReason()})
252+ }
253+ return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows)
254+ }
255+ return cmd
256+}
@@ -0,0 +1,256 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "strings"
9+
10+ "connectrpc.com/connect"
11+ "github.com/spf13/cobra"
12+ "google.golang.org/protobuf/encoding/protojson"
13+ "google.golang.org/protobuf/proto"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+)
17+
18+// mirrorJSON keeps the API's snake_case field names, including unset defaults.
19+func mirrorJSON(cmd *cobra.Command, msg proto.Message) error {
20+ b, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(msg)
21+ if err != nil {
22+ return err
23+ }
24+ _, err = fmt.Fprintln(cmd.OutOrStdout(), string(b))
25+ return err
26+}
27+
28+func newMirrorCmd(env *cliEnv) *cobra.Command {
29+ cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}
30+ key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}
31+ for _, rotate := range []bool{false, true} {
32+ name := "show"
33+ if rotate {
34+ name = "rotate"
35+ }
36+ sub := &cobra.Command{Use: name, Short: "Print the mirror authorized_keys line", Args: cobra.NoArgs,
37+ Long: "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe show into: gh ssh-key add - --title \"ermite mirror\". Rotation requires owner rights and takes effect immediately."}
38+ asJSON := addJSONFlag(sub)
39+ sub.RunE = func(cmd *cobra.Command, _ []string) error {
40+ c, err := env.client(cmd)
41+ if err != nil {
42+ return err
43+ }
44+ var k *ermite.MirrorKey
45+ if rotate {
46+ res, e := c.Mirrors().RotateMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateMirrorKeyRequest{}))
47+ if e != nil {
48+ return c.Describe(e)
49+ }
50+ k = res.Msg.GetKey()
51+ noticef(cmd, "remotes refuse pushes until this key is added")
52+ } else {
53+ res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))
54+ if e != nil {
55+ return c.Describe(e)
56+ }
57+ k = res.Msg.GetKey()
58+ }
59+ if *asJSON {
60+ return mirrorJSON(cmd, k)
61+ }
62+ _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))
63+ return err
64+ }
65+ key.AddCommand(sub)
66+ }
67+ cmd.AddCommand(key, newMirrorConfigCmd(env), newMirrorAddCmd(env), newMirrorStatusCmd(env), newMirrorSyncCmd(env))
68+ for _, name := range []string{"remove", "exclude", "include"} {
69+ sub := &cobra.Command{Use: name + " <repo>", Short: name + " a repository mirror", Args: cobra.ExactArgs(1)}
70+ sub.RunE = func(cmd *cobra.Command, args []string) error {
71+ c, err := env.client(cmd)
72+ if err != nil {
73+ return err
74+ }
75+ if name == "remove" {
76+ _, err = c.Mirrors().RemoveRepoMirror(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRepoMirrorRequest{Repo: args[0]}))
77+ } else {
78+ _, err = c.Mirrors().SetRepoMirrorExcluded(env.ctx(cmd), connect.NewRequest(&ermite.SetRepoMirrorExcludedRequest{Repo: args[0], Excluded: name == "exclude"}))
79+ }
80+ if err != nil {
81+ return c.Describe(err)
82+ }
83+ noticef(cmd, "%s: %s", name, clean(args[0]))
84+ return nil
85+ }
86+ cmd.AddCommand(sub)
87+ }
88+ return cmd
89+}
90+
91+func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
92+ var enabled, all bool
93+ var url, cron string
94+ cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Args: cobra.NoArgs}
95+ cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")
96+ cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")
97+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")
98+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches instead of only the default branch")
99+ asJSON := addJSONFlag(cmd)
100+ output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {
101+ if json {
102+ return mirrorJSON(cmd, g)
103+ }
104+ return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})
105+ }
106+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
107+ c, err := env.client(cmd)
108+ if err != nil {
109+ return err
110+ }
111+ req := &ermite.SetMirrorConfigRequest{}
112+ if cmd.Flags().Changed("enabled") {
113+ req.Enabled = &enabled
114+ }
115+ if cmd.Flags().Changed("url") {
116+ req.UrlTemplate = &url
117+ }
118+ if cmd.Flags().Changed("cron") {
119+ req.Cron = &cron
120+ }
121+ if cmd.Flags().Changed("all-branches") {
122+ req.AllBranches = &all
123+ }
124+ if req.Enabled == nil && req.UrlTemplate == nil && req.Cron == nil && req.AllBranches == nil {
125+ return fmt.Errorf("specify at least one config flag, or use `mirror config show`")
126+ }
127+ res, err := c.Mirrors().SetMirrorConfig(env.ctx(cmd), connect.NewRequest(req))
128+ if err != nil {
129+ return c.Describe(err)
130+ }
131+ return output(cmd, res.Msg.GetConfig(), *asJSON)
132+ }
133+ show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Args: cobra.NoArgs}
134+ showJSON := addJSONFlag(show)
135+ show.RunE = func(cmd *cobra.Command, _ []string) error {
136+ c, err := env.client(cmd)
137+ if err != nil {
138+ return err
139+ }
140+ res, err := c.Mirrors().GetMirrorConfig(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorConfigRequest{}))
141+ if err != nil {
142+ return c.Describe(err)
143+ }
144+ return output(cmd, res.Msg.GetConfig(), *showJSON)
145+ }
146+ cmd.AddCommand(show)
147+ return cmd
148+}
149+
150+func newMirrorAddCmd(env *cliEnv) *cobra.Command {
151+ var cron string
152+ var all bool
153+ cmd := &cobra.Command{Use: "add <repo> <url>", Short: "Set a repository mirror override", Args: cobra.ExactArgs(2)}
154+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (empty inherits global)")
155+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches (omitted inherits global)")
156+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
157+ c, err := env.client(cmd)
158+ if err != nil {
159+ return err
160+ }
161+ req := &ermite.SetRepoMirrorRequest{Repo: args[0], Url: args[1], Cron: cron}
162+ if cmd.Flags().Changed("all-branches") {
163+ req.AllBranches = &all
164+ }
165+ _, err = c.Mirrors().SetRepoMirror(env.ctx(cmd), connect.NewRequest(req))
166+ if err != nil {
167+ return c.Describe(err)
168+ }
169+ noticef(cmd, "mirror configured for %s", clean(args[0]))
170+ return nil
171+ }
172+ return cmd
173+}
174+
175+func mirrorStateName(s ermite.MirrorState) string {
176+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_STATE_"))
177+}
178+
179+func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
180+ cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Args: cobra.MaximumNArgs(1)}
181+ asJSON := addJSONFlag(cmd)
182+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
183+ c, err := env.client(cmd)
184+ if err != nil {
185+ return err
186+ }
187+ var statuses []*ermite.MirrorStatus
188+ if len(args) == 1 {
189+ res, err := c.Mirrors().GetMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorStatusRequest{Repo: args[0]}))
190+ if err != nil {
191+ return c.Describe(err)
192+ }
193+ if *asJSON {
194+ return mirrorJSON(cmd, res.Msg.GetStatus())
195+ }
196+ statuses = append(statuses, res.Msg.GetStatus())
197+ } else {
198+ res, err := c.Mirrors().ListMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.ListMirrorStatusRequest{}))
199+ if err != nil {
200+ return c.Describe(err)
201+ }
202+ if *asJSON {
203+ // Marshal each status with proto JSON, but retain the CLI list convention: an array.
204+ items := make([]string, 0, len(res.Msg.GetStatuses()))
205+ for _, s := range res.Msg.GetStatuses() {
206+ b, e := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(s)
207+ if e != nil {
208+ return e
209+ }
210+ items = append(items, string(b))
211+ }
212+ _, err = fmt.Fprintln(cmd.OutOrStdout(), "["+strings.Join(items, ",")+"]")
213+ return err
214+ }
215+ statuses = res.Msg.GetStatuses()
216+ }
217+ rows := make([][]string, 0, len(statuses))
218+ for _, s := range statuses {
219+ rows = append(rows, []string{s.GetTarget().GetRepo(), mirrorStateName(s.GetState()), s.GetReason(), shortTime(s.GetLastSuccessAt(), "-"), shortTime(s.GetNextRunAt(), "-"), strconv.Itoa(int(s.GetConsecutiveFailures()))})
220+ }
221+ return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "REASON", "LAST SUCCESS", "NEXT RUN", "FAILURES"}, rows)
222+ }
223+ return cmd
224+}
225+
226+func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
227+ var all, force bool
228+ cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
229+ if (all && len(args) == 0) || (!all && len(args) == 1) {
230+ return nil
231+ }
232+ return fmt.Errorf("specify exactly one repository or --all")
233+ }}
234+ cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors")
235+ cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push")
236+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
237+ c, err := env.client(cmd)
238+ if err != nil {
239+ return err
240+ }
241+ req := &ermite.SyncMirrorRequest{All: all, Force: force}
242+ if !all {
243+ req.Repo = args[0]
244+ }
245+ res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req))
246+ if err != nil {
247+ return c.Describe(err)
248+ }
249+ rows := make([][]string, 0, len(res.Msg.GetResults()))
250+ for _, r := range res.Msg.GetResults() {
251+ rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), r.GetStatus().GetReason()})
252+ }
253+ return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows)
254+ }
255+ return cmd
256+}
A internal/app/cli_mirror_e2e_test.go +121 -0
@@ -0,0 +1,121 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+ "golang.org/x/crypto/ssh"
15+
16+ "bnema.dev/ermite/internal/adapters/gitcli"
17+ "bnema.dev/ermite/internal/adapters/gitpush"
18+ "bnema.dev/ermite/internal/config"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// Pushes mark mirrors dirty; a due cron tick performs the asynchronous sync.
24+// Advancing the injected clock exercises that contract without waiting five minutes.
25+func TestMirrorCLIEndToEnd(t *testing.T) {
26+ clk := &testClock{}
27+ clk.set("2025-12-31 23:59:00")
28+ transport := t.TempDir()
29+ var dataDir string
30+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
31+ o.mirrors = func(c *usecase.MirrorsConfig) {
32+ c.AllowAnyURL = true
33+ c.Clock = clk
34+ c.ScheduleTick = time.Hour
35+ c.Pusher = gitpush.New(gitpush.Config{
36+ Bin: requireGit(t), Path: os.Getenv("PATH"), Home: transport,
37+ KnownHosts: filepath.Join(transport, "known_hosts"), TempDir: filepath.Join(transport, "keys"),
38+ AllowedProtocols: "file", RepoDir: func(repo domain.RepoName) string { return gitcli.RepoDir(dataDir, repo) },
39+ })
40+ }
41+ })
42+ // The transport needs the harness data directory, which is known after newForge.
43+ // Replace the test-only closure's captured directory before any sync is queued.
44+ dataDir = h.data
45+ c := &cliHarness{harness: h, url: h.srv.URL}
46+ run := func(args ...string) result { return c.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
47+ h.createRepo("proj", domain.VisibilityPrivate)
48+ remote := filepath.Join(t.TempDir(), "remote.git")
49+ h.mustGit(transport, "init", "--bare", remote)
50+ run("add", "proj", "file://"+remote, "--cron", "*/5 * * * *").ok(t)
51+ key := run("key", "show").ok(t)
52+ assert.Empty(t, key.stderr)
53+ pub, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key.stdout))
54+ require.NoError(t, err)
55+ assert.Empty(t, rest)
56+ assert.Equal(t, "ssh-ed25519", pub.Type())
57+ assert.Equal(t, 1, strings.Count(key.stdout, "\n"))
58+ assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")
59+ events := h.forge.Bus.Subscribe(h.ctx)
60+ wt := h.workTree("main", "README.md")
61+ h.mustGit(wt, "tag", "v1")
62+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main", "--tags")
63+ clk.set("2026-01-01 00:00:00")
64+ h.forge.Mirrors.ScheduleNow(h.ctx)
65+ require.Eventually(t, func() bool {
66+ s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
67+ return err == nil && !s.Status.LastSuccessAt.IsZero()
68+ }, 10*time.Second, 10*time.Millisecond)
69+ main := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
70+ assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
71+ assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/tags/v1")))
72+ clk.set("2026-01-01 00:02:00")
73+ assert.Contains(t, run("sync", "proj").ok(t).stdout, "true", "unchanged refs are a no-op")
74+
75+ // Advance the remote independently, so the next atomic mirror push is rejected.
76+ other := filepath.Join(t.TempDir(), "other")
77+ h.mustGit(transport, "clone", "--branch", "main", remote, other)
78+ h.mustGit(other, "commit", "--allow-empty", "-m", "remote-only")
79+ h.mustGit(other, "push", "origin", "main")
80+ h.mustGit(wt, "commit", "--allow-empty", "-m", "forge-only")
81+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
82+ clk.set("2026-01-01 00:05:00")
83+ h.forge.Mirrors.ScheduleNow(h.ctx)
84+ require.Eventually(t, func() bool {
85+ s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
86+ return err == nil && s.Status.State == domain.MirrorPaused
87+ }, 10*time.Second, 10*time.Millisecond)
88+ status := run("status", "proj").ok(t).stdout
89+ assert.Contains(t, status, "paused")
90+ assert.Contains(t, status, "remote has commits")
91+ assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class":"diverged"`)
92+ h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")
93+ warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
94+ assert.Contains(t, warning, "remote: ermite: mirror")
95+ assert.Contains(t, warning, "paused")
96+ token := c.signIn(t)
97+ page := (browser{h}).do("GET", "/proj", map[string]string{"Cookie": sessionCookie + "=" + token}, "")
98+ assert.Equal(t, 200, page.status)
99+ assert.Contains(t, page.body, "Mirror paused")
100+ assert.Contains(t, page.body, "ermite mirror status proj")
101+
102+ // Repair the remote, then force a normal (never forced) Git push to recover.
103+ latest := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
104+ h.mustGit(wt, "push", "--force", remote, "main") // deliberate remote repair, not mirror behavior
105+ run("sync", "proj", "--force").ok(t)
106+ assert.Equal(t, latest, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
107+ assert.Contains(t, run("status", "proj").ok(t).stdout, "ok")
108+ run("sync", "--all", "--force").ok(t)
109+ var kinds []string
110+ for {
111+ select {
112+ case event := <-events:
113+ if event.EventKind() == "mirror.failed" || event.EventKind() == "mirror.recovered" {
114+ kinds = append(kinds, event.EventKind())
115+ }
116+ default:
117+ assert.Equal(t, []string{"mirror.failed", "mirror.recovered"}, kinds)
118+ return
119+ }
120+ }
121+}
@@ -0,0 +1,121 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+ "golang.org/x/crypto/ssh"
15+
16+ "bnema.dev/ermite/internal/adapters/gitcli"
17+ "bnema.dev/ermite/internal/adapters/gitpush"
18+ "bnema.dev/ermite/internal/config"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// Pushes mark mirrors dirty; a due cron tick performs the asynchronous sync.
24+// Advancing the injected clock exercises that contract without waiting five minutes.
25+func TestMirrorCLIEndToEnd(t *testing.T) {
26+ clk := &testClock{}
27+ clk.set("2025-12-31 23:59:00")
28+ transport := t.TempDir()
29+ var dataDir string
30+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
31+ o.mirrors = func(c *usecase.MirrorsConfig) {
32+ c.AllowAnyURL = true
33+ c.Clock = clk
34+ c.ScheduleTick = time.Hour
35+ c.Pusher = gitpush.New(gitpush.Config{
36+ Bin: requireGit(t), Path: os.Getenv("PATH"), Home: transport,
37+ KnownHosts: filepath.Join(transport, "known_hosts"), TempDir: filepath.Join(transport, "keys"),
38+ AllowedProtocols: "file", RepoDir: func(repo domain.RepoName) string { return gitcli.RepoDir(dataDir, repo) },
39+ })
40+ }
41+ })
42+ // The transport needs the harness data directory, which is known after newForge.
43+ // Replace the test-only closure's captured directory before any sync is queued.
44+ dataDir = h.data
45+ c := &cliHarness{harness: h, url: h.srv.URL}
46+ run := func(args ...string) result { return c.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
47+ h.createRepo("proj", domain.VisibilityPrivate)
48+ remote := filepath.Join(t.TempDir(), "remote.git")
49+ h.mustGit(transport, "init", "--bare", remote)
50+ run("add", "proj", "file://"+remote, "--cron", "*/5 * * * *").ok(t)
51+ key := run("key", "show").ok(t)
52+ assert.Empty(t, key.stderr)
53+ pub, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key.stdout))
54+ require.NoError(t, err)
55+ assert.Empty(t, rest)
56+ assert.Equal(t, "ssh-ed25519", pub.Type())
57+ assert.Equal(t, 1, strings.Count(key.stdout, "\n"))
58+ assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")
59+ events := h.forge.Bus.Subscribe(h.ctx)
60+ wt := h.workTree("main", "README.md")
61+ h.mustGit(wt, "tag", "v1")
62+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main", "--tags")
63+ clk.set("2026-01-01 00:00:00")
64+ h.forge.Mirrors.ScheduleNow(h.ctx)
65+ require.Eventually(t, func() bool {
66+ s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
67+ return err == nil && !s.Status.LastSuccessAt.IsZero()
68+ }, 10*time.Second, 10*time.Millisecond)
69+ main := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
70+ assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
71+ assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/tags/v1")))
72+ clk.set("2026-01-01 00:02:00")
73+ assert.Contains(t, run("sync", "proj").ok(t).stdout, "true", "unchanged refs are a no-op")
74+
75+ // Advance the remote independently, so the next atomic mirror push is rejected.
76+ other := filepath.Join(t.TempDir(), "other")
77+ h.mustGit(transport, "clone", "--branch", "main", remote, other)
78+ h.mustGit(other, "commit", "--allow-empty", "-m", "remote-only")
79+ h.mustGit(other, "push", "origin", "main")
80+ h.mustGit(wt, "commit", "--allow-empty", "-m", "forge-only")
81+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
82+ clk.set("2026-01-01 00:05:00")
83+ h.forge.Mirrors.ScheduleNow(h.ctx)
84+ require.Eventually(t, func() bool {
85+ s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
86+ return err == nil && s.Status.State == domain.MirrorPaused
87+ }, 10*time.Second, 10*time.Millisecond)
88+ status := run("status", "proj").ok(t).stdout
89+ assert.Contains(t, status, "paused")
90+ assert.Contains(t, status, "remote has commits")
91+ assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class":"diverged"`)
92+ h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")
93+ warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
94+ assert.Contains(t, warning, "remote: ermite: mirror")
95+ assert.Contains(t, warning, "paused")
96+ token := c.signIn(t)
97+ page := (browser{h}).do("GET", "/proj", map[string]string{"Cookie": sessionCookie + "=" + token}, "")
98+ assert.Equal(t, 200, page.status)
99+ assert.Contains(t, page.body, "Mirror paused")
100+ assert.Contains(t, page.body, "ermite mirror status proj")
101+
102+ // Repair the remote, then force a normal (never forced) Git push to recover.
103+ latest := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
104+ h.mustGit(wt, "push", "--force", remote, "main") // deliberate remote repair, not mirror behavior
105+ run("sync", "proj", "--force").ok(t)
106+ assert.Equal(t, latest, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
107+ assert.Contains(t, run("status", "proj").ok(t).stdout, "ok")
108+ run("sync", "--all", "--force").ok(t)
109+ var kinds []string
110+ for {
111+ select {
112+ case event := <-events:
113+ if event.EventKind() == "mirror.failed" || event.EventKind() == "mirror.recovered" {
114+ kinds = append(kinds, event.EventKind())
115+ }
116+ default:
117+ assert.Equal(t, []string{"mirror.failed", "mirror.recovered"}, kinds)
118+ return
119+ }
120+ }
121+}
A internal/app/cli_mirror_test.go +64 -0
@@ -0,0 +1,64 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestMirrorCLIArguments(t *testing.T) {
14+ for _, args := range [][]string{
15+ {"mirror", "sync"}, {"mirror", "sync", "repo", "--all"}, {"mirror", "sync", "a", "b"},
16+ {"mirror", "add", "repo"}, {"mirror", "remove"}, {"mirror", "exclude"}, {"mirror", "include"},
17+ {"mirror", "key", "show", "extra"}, {"mirror", "key", "rotate", "extra"}, {"mirror", "status", "a", "b"},
18+ } {
19+ t.Run(strings.Join(args, " "), func(t *testing.T) {
20+ r := runCLI(t, nil, "", args...)
21+ require.Error(t, r.err)
22+ assert.NotContains(t, r.err.Error(), "ERMITE_URL", "arguments must be rejected before loading credentials")
23+ })
24+ }
25+ r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
26+ assert.Contains(t, r.stdout, "never force-pushes")
27+ assert.Contains(t, r.stdout, "lifts a pause")
28+}
29+
30+func TestMirrorCLIConfiguration(t *testing.T) {
31+ h := newSecretsCLI(t)
32+ run := func(args ...string) result { return h.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
33+ run("config", "--enabled", "--url", "git@example.test:owner/{repo}.git", "--cron", "*/5 * * * *", "--all-branches").ok(t)
34+ r := run("config", "show", "--json").ok(t)
35+ g := decode[map[string]any](t, r.stdout)
36+ assert.Equal(t, true, g["enabled"])
37+ assert.Equal(t, true, g["all_branches"])
38+ assert.Equal(t, "git@example.test:owner/{repo}.git", g["url_template"])
39+ run("config", "--enabled=false", "--all-branches=false").ok(t)
40+ g = decode[map[string]any](t, run("config", "show", "--json").ok(t).stdout)
41+ assert.Equal(t, false, g["enabled"])
42+ assert.Equal(t, false, g["all_branches"])
43+ assert.Equal(t, "*/5 * * * *", g["cron"], "omitted fields are not cleared")
44+ run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)
45+ r = run("status", "proj", "--json").ok(t)
46+ s := decode[map[string]any](t, r.stdout)
47+ target := s["target"].(map[string]any)
48+ assert.Equal(t, "MIRROR_SOURCE_OVERRIDE", target["source"])
49+ assert.Equal(t, false, target["all_branches"])
50+ assert.Contains(t, run("status").ok(t).stdout, "FAILURES")
51+ assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)
52+ run("remove", "proj").ok(t)
53+ run("exclude", "proj").ok(t)
54+ s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)
55+ assert.Equal(t, "MIRROR_SOURCE_EXCLUDED", s["target"].(map[string]any)["source"])
56+ run("include", "proj").ok(t)
57+ first := run("key", "show").ok(t)
58+ assert.Empty(t, first.stderr)
59+ rotated := run("key", "rotate").ok(t)
60+ assert.NotEqual(t, first.stdout, rotated.stdout)
61+ assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")
62+ assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
63+ assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))
64+}
@@ -0,0 +1,64 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestMirrorCLIArguments(t *testing.T) {
14+ for _, args := range [][]string{
15+ {"mirror", "sync"}, {"mirror", "sync", "repo", "--all"}, {"mirror", "sync", "a", "b"},
16+ {"mirror", "add", "repo"}, {"mirror", "remove"}, {"mirror", "exclude"}, {"mirror", "include"},
17+ {"mirror", "key", "show", "extra"}, {"mirror", "key", "rotate", "extra"}, {"mirror", "status", "a", "b"},
18+ } {
19+ t.Run(strings.Join(args, " "), func(t *testing.T) {
20+ r := runCLI(t, nil, "", args...)
21+ require.Error(t, r.err)
22+ assert.NotContains(t, r.err.Error(), "ERMITE_URL", "arguments must be rejected before loading credentials")
23+ })
24+ }
25+ r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
26+ assert.Contains(t, r.stdout, "never force-pushes")
27+ assert.Contains(t, r.stdout, "lifts a pause")
28+}
29+
30+func TestMirrorCLIConfiguration(t *testing.T) {
31+ h := newSecretsCLI(t)
32+ run := func(args ...string) result { return h.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
33+ run("config", "--enabled", "--url", "git@example.test:owner/{repo}.git", "--cron", "*/5 * * * *", "--all-branches").ok(t)
34+ r := run("config", "show", "--json").ok(t)
35+ g := decode[map[string]any](t, r.stdout)
36+ assert.Equal(t, true, g["enabled"])
37+ assert.Equal(t, true, g["all_branches"])
38+ assert.Equal(t, "git@example.test:owner/{repo}.git", g["url_template"])
39+ run("config", "--enabled=false", "--all-branches=false").ok(t)
40+ g = decode[map[string]any](t, run("config", "show", "--json").ok(t).stdout)
41+ assert.Equal(t, false, g["enabled"])
42+ assert.Equal(t, false, g["all_branches"])
43+ assert.Equal(t, "*/5 * * * *", g["cron"], "omitted fields are not cleared")
44+ run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)
45+ r = run("status", "proj", "--json").ok(t)
46+ s := decode[map[string]any](t, r.stdout)
47+ target := s["target"].(map[string]any)
48+ assert.Equal(t, "MIRROR_SOURCE_OVERRIDE", target["source"])
49+ assert.Equal(t, false, target["all_branches"])
50+ assert.Contains(t, run("status").ok(t).stdout, "FAILURES")
51+ assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)
52+ run("remove", "proj").ok(t)
53+ run("exclude", "proj").ok(t)
54+ s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)
55+ assert.Equal(t, "MIRROR_SOURCE_EXCLUDED", s["target"].(map[string]any)["source"])
56+ run("include", "proj").ok(t)
57+ first := run("key", "show").ok(t)
58+ assert.Empty(t, first.stderr)
59+ rotated := run("key", "rotate").ok(t)
60+ assert.NotEqual(t, first.stdout, rotated.stdout)
61+ assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")
62+ assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
63+ assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))
64+}
M internal/app/forge.go +34 -0
@@ -26,6 +26,7 @@ import (
2626 "bnema.dev/ermite/internal/adapters/githttp"
2727 "bnema.dev/ermite/internal/adapters/githubapi"
2828 "bnema.dev/ermite/internal/adapters/githubraw"
29+ "bnema.dev/ermite/internal/adapters/gitpush"
2930 "bnema.dev/ermite/internal/adapters/gitremote"
3031 "bnema.dev/ermite/internal/adapters/httpserver"
3132 "bnema.dev/ermite/internal/adapters/kvstore"
@@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes
4950
5051 // options are the parts of the wiring that tests replace.
5152 type options struct {
53+ // pusher and mirrors override mirror transport/settings in local tests only.
54+ pusher ports.MirrorPusher
55+ mirrors func(*usecase.MirrorsConfig)
5256 // hookExe is the executable the hook scripts exec; empty means this
5357 // process's own executable.
5458 hookExe string
@@ -151,6 +155,8 @@ type forge struct {
151155 CI *usecase.CI
152156 // Settings are the secrets, variables and environments of CI.
153157 Settings *usecase.CISettings
158+ // Mirrors exposes Warning and Badge for the hook server and web composition.
159+ Mirrors *usecase.Mirrors
154160 // OIDC is the provider of ID tokens, disabled without a secrets key.
155161 OIDC *usecase.OIDC
156162 API *rpc.API
@@ -274,6 +280,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
274280 }
275281 sealer = box
276282 }
283+ pusher := opts.pusher
284+ if pusher == nil {
285+ pusher = gitpush.New(gitpush.Config{
286+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
287+ KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"),
288+ TempDir: filepath.Join(cfg.DataDir, "run", "mirror"),
289+ RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
290+ })
291+ }
292+ mirrorCfg := usecase.MirrorsConfig{
293+ Store: store, Keys: store, Sealer: sealer, Pusher: pusher,
294+ Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL,
295+ }
296+ if opts.mirrors != nil {
297+ opts.mirrors(&mirrorCfg)
298+ }
299+ f.Mirrors = usecase.NewMirrors(mirrorCfg)
300+ f.Pushes.WithMirror(f.Mirrors)
301+ f.hooks.WithMirrorWarner(f.Mirrors)
302+
277303 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
278304 if err != nil {
279305 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
@@ -400,6 +426,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
400426 }
401427 f.API = rpc.New(rpc.Config{
402428 Auth: f.Auth,
429+ Mirrors: f.Mirrors,
403430 Repos: f.Repos,
404431 Tokens: f.Tokens,
405432 Browse: f.Browse,
@@ -462,6 +489,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
462489 if err != nil {
463490 return nil, fmt.Errorf("web: %w", err)
464491 }
492+ if s, ok := site.(*web.Server); ok {
493+ s.WithMirrorWarner(f.Mirrors)
494+ }
465495 routes = append(routes, webRoutes(site)...)
466496 f.server = httpserver.New(httpserver.Config{
467497 Addr: cfg.Addr,
@@ -489,6 +519,7 @@ func (f *forge) Run(ctx context.Context) error {
489519 // outlives the HTTP server: requests still in flight while it drains are
490520 // pushes whose hooks need it. A failure of either stops both.
491521 func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
522+ f.Mirrors.Start()
492523 ctx, cancel := context.WithCancel(ctx)
493524 defer cancel()
494525 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
@@ -517,6 +548,9 @@ func (f *forge) Close(ctx context.Context) {
517548 if f.MergeRequests != nil {
518549 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
519550 }
551+ if f.Mirrors != nil {
552+ f.Mirrors.Close(ctx)
553+ }
520554 if f.CI != nil {
521555 f.CI.Close(ctx)
522556 }
@@ -26,6 +26,7 @@ import (
26 "bnema.dev/ermite/internal/adapters/githttp"26 "bnema.dev/ermite/internal/adapters/githttp"
27 "bnema.dev/ermite/internal/adapters/githubapi"27 "bnema.dev/ermite/internal/adapters/githubapi"
28 "bnema.dev/ermite/internal/adapters/githubraw"28 "bnema.dev/ermite/internal/adapters/githubraw"
29+ "bnema.dev/ermite/internal/adapters/gitpush"
29 "bnema.dev/ermite/internal/adapters/gitremote"30 "bnema.dev/ermite/internal/adapters/gitremote"
30 "bnema.dev/ermite/internal/adapters/httpserver"31 "bnema.dev/ermite/internal/adapters/httpserver"
31 "bnema.dev/ermite/internal/adapters/kvstore"32 "bnema.dev/ermite/internal/adapters/kvstore"
@@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes
49 50
50 // options are the parts of the wiring that tests replace.51 // options are the parts of the wiring that tests replace.
51 type options struct {52 type options struct {
53+ // pusher and mirrors override mirror transport/settings in local tests only.
54+ pusher ports.MirrorPusher
55+ mirrors func(*usecase.MirrorsConfig)
52 // hookExe is the executable the hook scripts exec; empty means this56 // hookExe is the executable the hook scripts exec; empty means this
53 // process's own executable.57 // process's own executable.
54 hookExe string58 hookExe string
@@ -151,6 +155,8 @@ type forge struct {
151 CI *usecase.CI155 CI *usecase.CI
152 // Settings are the secrets, variables and environments of CI.156 // Settings are the secrets, variables and environments of CI.
153 Settings *usecase.CISettings157 Settings *usecase.CISettings
158+ // Mirrors exposes Warning and Badge for the hook server and web composition.
159+ Mirrors *usecase.Mirrors
154 // OIDC is the provider of ID tokens, disabled without a secrets key.160 // OIDC is the provider of ID tokens, disabled without a secrets key.
155 OIDC *usecase.OIDC161 OIDC *usecase.OIDC
156 API *rpc.API162 API *rpc.API
@@ -274,6 +280,26 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
274 }280 }
275 sealer = box281 sealer = box
276 }282 }
283+ pusher := opts.pusher
284+ if pusher == nil {
285+ pusher = gitpush.New(gitpush.Config{
286+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
287+ KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"),
288+ TempDir: filepath.Join(cfg.DataDir, "run", "mirror"),
289+ RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
290+ })
291+ }
292+ mirrorCfg := usecase.MirrorsConfig{
293+ Store: store, Keys: store, Sealer: sealer, Pusher: pusher,
294+ Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL,
295+ }
296+ if opts.mirrors != nil {
297+ opts.mirrors(&mirrorCfg)
298+ }
299+ f.Mirrors = usecase.NewMirrors(mirrorCfg)
300+ f.Pushes.WithMirror(f.Mirrors)
301+ f.hooks.WithMirrorWarner(f.Mirrors)
302+
277 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)303 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
278 if err != nil {304 if err != nil {
279 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)305 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
@@ -400,6 +426,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
400 }426 }
401 f.API = rpc.New(rpc.Config{427 f.API = rpc.New(rpc.Config{
402 Auth: f.Auth,428 Auth: f.Auth,
429+ Mirrors: f.Mirrors,
403 Repos: f.Repos,430 Repos: f.Repos,
404 Tokens: f.Tokens,431 Tokens: f.Tokens,
405 Browse: f.Browse,432 Browse: f.Browse,
@@ -462,6 +489,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
462 if err != nil {489 if err != nil {
463 return nil, fmt.Errorf("web: %w", err)490 return nil, fmt.Errorf("web: %w", err)
464 }491 }
492+ if s, ok := site.(*web.Server); ok {
493+ s.WithMirrorWarner(f.Mirrors)
494+ }
465 routes = append(routes, webRoutes(site)...)495 routes = append(routes, webRoutes(site)...)
466 f.server = httpserver.New(httpserver.Config{496 f.server = httpserver.New(httpserver.Config{
467 Addr: cfg.Addr,497 Addr: cfg.Addr,
@@ -489,6 +519,7 @@ func (f *forge) Run(ctx context.Context) error {
489 // outlives the HTTP server: requests still in flight while it drains are519 // outlives the HTTP server: requests still in flight while it drains are
490 // pushes whose hooks need it. A failure of either stops both.520 // pushes whose hooks need it. A failure of either stops both.
491 func (f *forge) Serve(ctx context.Context, ln net.Listener) error {521 func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
522+ f.Mirrors.Start()
492 ctx, cancel := context.WithCancel(ctx)523 ctx, cancel := context.WithCancel(ctx)
493 defer cancel()524 defer cancel()
494 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))525 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
@@ -517,6 +548,9 @@ func (f *forge) Close(ctx context.Context) {
517 if f.MergeRequests != nil {548 if f.MergeRequests != nil {
518 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability549 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
519 }550 }
551+ if f.Mirrors != nil {
552+ f.Mirrors.Close(ctx)
553+ }
520 if f.CI != nil {554 if f.CI != nil {
521 f.CI.Close(ctx)555 f.CI.Close(ctx)
522 }556 }
M internal/app/root.go +1 -1
@@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3030 }
3131 root.AddCommand(
3232 newServeCmd(), newHookCmd(),
33- newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
33+ newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
3434 newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
3535 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
@@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
30 }30 }
31 root.AddCommand(31 root.AddCommand(
32 newServeCmd(), newHookCmd(),32 newServeCmd(), newHookCmd(),
33- newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),33+ newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34 newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),34 newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
36 )36 )

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 3

Tags 43

↑↓ moveenter openesc close