detached ff6a379

feat(images): show registry tags and keep latest plus previous versions

commit
ff6a37934f45a4584a82cad7108d67fcefdaaa98
author
bnema · · 2026-02-10 21:24 UTC
parent
c00708a
signature
unsigned
12 files · +285 -38
M docs/cli/images.md +5 -3
@@ -10,7 +10,7 @@ gordon images <subcommand>
1010
1111 Subcommands:
1212
13-- `list` - List runtime images.
13+- `list` - List runtime images and registry tags.
1414 - `prune` - Prune dangling runtime images and optionally registry data.
1515
1616 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
@@ -22,6 +22,7 @@ gordon images list
2222 ```
2323
2424 Shows image rows with repository, tag, size, creation time, image id, and dangling status.
25+Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`.
2526
2627 ## gordon images prune
2728
@@ -34,7 +35,7 @@ Flags:
3435 | Flag | Default | Description |
3536 |------|---------|-------------|
3637 | `--dry-run` | `false` | Show prune behavior without applying changes |
37-| `--keep` | `0` | Number of latest tags to keep per repository (`0` disables registry cleanup) |
38+| `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) |
3839 | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off |
3940
4041 `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used.
@@ -44,6 +45,7 @@ Behavior notes:
4445 - `--runtime-only` forces `keep_last = 0`.
4546 - `--keep` must be `>= 0`.
4647 - `--dry-run` reports what would be pruned.
48+- When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags.
4749
4850 ## Examples
4951
@@ -54,7 +56,7 @@ gordon images list
5456 # Prune dangling runtime images only
5557 gordon images prune --runtime-only
5658
57-# Prune runtime images and keep 3 newest tags per repository
59+# Prune runtime images and keep latest + 3 previous tags per repository
5860 gordon images prune --keep 3
5961
6062 # Preview cleanup without applying
@@ -10,7 +10,7 @@ gordon images <subcommand>
10 10
11 Subcommands:11 Subcommands:
12 12
13-- `list` - List runtime images.13+- `list` - List runtime images and registry tags.
14 - `prune` - Prune dangling runtime images and optionally registry data.14 - `prune` - Prune dangling runtime images and optionally registry data.
15 15
16 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).16 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
@@ -22,6 +22,7 @@ gordon images list
22 ```22 ```
23 23
24 Shows image rows with repository, tag, size, creation time, image id, and dangling status.24 Shows image rows with repository, tag, size, creation time, image id, and dangling status.
25+Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`.
25 26
26 ## gordon images prune27 ## gordon images prune
27 28
@@ -34,7 +35,7 @@ Flags:
34 | Flag | Default | Description |35 | Flag | Default | Description |
35 |------|---------|-------------|36 |------|---------|-------------|
36 | `--dry-run` | `false` | Show prune behavior without applying changes |37 | `--dry-run` | `false` | Show prune behavior without applying changes |
37-| `--keep` | `0` | Number of latest tags to keep per repository (`0` disables registry cleanup) |38+| `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) |
38 | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off |39 | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off |
39 40
40 `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used.41 `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used.
@@ -44,6 +45,7 @@ Behavior notes:
44 - `--runtime-only` forces `keep_last = 0`.45 - `--runtime-only` forces `keep_last = 0`.
45 - `--keep` must be `>= 0`.46 - `--keep` must be `>= 0`.
46 - `--dry-run` reports what would be pruned.47 - `--dry-run` reports what would be pruned.
48+- When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags.
47 49
48 ## Examples50 ## Examples
49 51
@@ -54,7 +56,7 @@ gordon images list
54 # Prune dangling runtime images only56 # Prune dangling runtime images only
55 gordon images prune --runtime-only57 gordon images prune --runtime-only
56 58
57-# Prune runtime images and keep 3 newest tags per repository59+# Prune runtime images and keep latest + 3 previous tags per repository
58 gordon images prune --keep 360 gordon images prune --keep 3
59 61
60 # Preview cleanup without applying62 # Preview cleanup without applying
M docs/cli/rollback.md +9 -3
@@ -8,6 +8,7 @@ Roll back a route to a previous image tag.
88
99 ```bash
1010 gordon rollback <domain> [options]
11+gordon rollback list <domain>
1112 ```
1213
1314 ### Arguments
@@ -26,9 +27,11 @@ gordon rollback <domain> [options]
2627
2728 ### Description
2829
29-`gordon rollback` lists available tags in the Gordon registry for the route's
30-repository, then deploys the selected version. Semver tags are sorted in
31-descending order first, followed by non-semver tags.
30+`gordon rollback` deploys a selected tag for the route's repository.
31+Semver tags are sorted in descending order first, followed by non-semver tags.
32+
33+`gordon rollback list <domain>` only lists available tags and marks the current
34+tag without deploying.
3235
3336 If the selected tag matches the current running tag, no action is taken.
3437
@@ -40,6 +43,9 @@ gordon rollback myapp.example.com
4043
4144 # Roll back to a specific tag
4245 gordon rollback myapp.example.com --tag v1.2.0
46+
47+# List available tags for a domain
48+gordon rollback list myapp.example.com
4349 ```
4450
4551 ### Notes
@@ -8,6 +8,7 @@ Roll back a route to a previous image tag.
8 8
9 ```bash9 ```bash
10 gordon rollback <domain> [options]10 gordon rollback <domain> [options]
11+gordon rollback list <domain>
11 ```12 ```
12 13
13 ### Arguments14 ### Arguments
@@ -26,9 +27,11 @@ gordon rollback <domain> [options]
26 27
27 ### Description28 ### Description
28 29
29-`gordon rollback` lists available tags in the Gordon registry for the route's30+`gordon rollback` deploys a selected tag for the route's repository.
30-repository, then deploys the selected version. Semver tags are sorted in31+Semver tags are sorted in descending order first, followed by non-semver tags.
31-descending order first, followed by non-semver tags.32+
33+`gordon rollback list <domain>` only lists available tags and marks the current
34+tag without deploying.
32 35
33 If the selected tag matches the current running tag, no action is taken.36 If the selected tag matches the current running tag, no action is taken.
34 37
@@ -40,6 +43,9 @@ gordon rollback myapp.example.com
40 43
41 # Roll back to a specific tag44 # Roll back to a specific tag
42 gordon rollback myapp.example.com --tag v1.2.045 gordon rollback myapp.example.com --tag v1.2.0
46+
47+# List available tags for a domain
48+gordon rollback list myapp.example.com
43 ```49 ```
44 50
45 ### Notes51 ### Notes
M docs/config/images.md +2 -2
@@ -26,12 +26,12 @@ keep_last = 3
2626 |-----|------|---------|-------------|
2727 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
2828 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
29-| `images.prune.keep_last` | int | `3` | Number of newest tags kept per repository during registry cleanup |
29+| `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
3030
3131 ## Retention Behavior
3232
3333 - `latest` is always preserved.
34-- `keep_last` applies per repository.
34+- `keep_last` applies per repository and counts non-`latest` tags.
3535 - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).
3636 - Negative `keep_last` values are invalid.
3737
@@ -26,12 +26,12 @@ keep_last = 3
26 |-----|------|---------|-------------|26 |-----|------|---------|-------------|
27 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |27 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
28 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |28 | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
29-| `images.prune.keep_last` | int | `3` | Number of newest tags kept per repository during registry cleanup |29+| `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) |
30 30
31 ## Retention Behavior31 ## Retention Behavior
32 32
33 - `latest` is always preserved.33 - `latest` is always preserved.
34-- `keep_last` applies per repository.34+- `keep_last` applies per repository and counts non-`latest` tags.
35 - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).35 - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).
36 - Negative `keep_last` values are invalid.36 - Negative `keep_last` values are invalid.
37 37
M internal/adapters/dto/admin_images.go +1 -1
@@ -2,7 +2,7 @@ package dto
22
33 import "time"
44
5-// Image represents a runtime image in admin API responses.
5+// Image represents an image/tag in admin API responses.
66 type Image struct {
77 Repository string `json:"repository"`
88 Tag string `json:"tag"`
@@ -2,7 +2,7 @@ package dto
2 2
3 import "time"3 import "time"
4 4
5-// Image represents a runtime image in admin API responses.5+// Image represents an image/tag in admin API responses.
6 type Image struct {6 type Image struct {
7 Repository string `json:"repository"`7 Repository string `json:"repository"`
8 Tag string `json:"tag"`8 Tag string `json:"tag"`
M internal/adapters/in/cli/images.go +17 -4
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "io"
7+ "strings"
78 "text/tabwriter"
89 "time"
910
@@ -41,7 +42,7 @@ These commands currently require remote mode with a configured target.`,
4142 func newImagesListCmd() *cobra.Command {
4243 return &cobra.Command{
4344 Use: "list",
44- Short: "List runtime images",
45+ Short: "List runtime images and registry tags",
4546 RunE: func(cmd *cobra.Command, _ []string) error {
4647 client, isRemote := GetRemoteClient()
4748 if !isRemote {
@@ -70,7 +71,7 @@ func newImagesPruneCmd() *cobra.Command {
7071 }
7172
7273 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
73- cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of latest tags to keep per repository (0 disables registry cleanup)")
74+ cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)")
7475 cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")
7576
7677 return cmd
@@ -103,7 +104,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
103104 danglingCount++
104105 }
105106
106- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), img.ID, dangling); err != nil {
107+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil {
107108 return err
108109 }
109110 }
@@ -154,7 +155,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
154155 return err
155156 }
156157
157- _, err = fmt.Fprintf(out, "Registry: would keep last %d tags per repository\n", keepLast)
158+ _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
158159 return err
159160 }
160161
@@ -187,6 +188,10 @@ func formatImageCreatedAt(t time.Time) string {
187188 }
188189
189190 func formatImageSize(size int64) string {
191+ if size <= 0 {
192+ return "-"
193+ }
194+
190195 const unit = 1024
191196 if size < unit {
192197 return fmt.Sprintf("%d B", size)
@@ -198,3 +203,11 @@ func formatImageSize(size int64) string {
198203 }
199204 return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
200205 }
206+
207+func formatImageID(id string) string {
208+ if strings.TrimSpace(id) == "" {
209+ return "-"
210+ }
211+
212+ return id
213+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "io"6 "io"
7+ "strings"
7 "text/tabwriter"8 "text/tabwriter"
8 "time"9 "time"
9 10
@@ -41,7 +42,7 @@ These commands currently require remote mode with a configured target.`,
41 func newImagesListCmd() *cobra.Command {42 func newImagesListCmd() *cobra.Command {
42 return &cobra.Command{43 return &cobra.Command{
43 Use: "list",44 Use: "list",
44- Short: "List runtime images",45+ Short: "List runtime images and registry tags",
45 RunE: func(cmd *cobra.Command, _ []string) error {46 RunE: func(cmd *cobra.Command, _ []string) error {
46 client, isRemote := GetRemoteClient()47 client, isRemote := GetRemoteClient()
47 if !isRemote {48 if !isRemote {
@@ -70,7 +71,7 @@ func newImagesPruneCmd() *cobra.Command {
70 }71 }
71 72
72 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")73 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
73- cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of latest tags to keep per repository (0 disables registry cleanup)")74+ cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)")
74 cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")75 cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")
75 76
76 return cmd77 return cmd
@@ -103,7 +104,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
103 danglingCount++104 danglingCount++
104 }105 }
105 106
106- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), img.ID, dangling); err != nil {107+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil {
107 return err108 return err
108 }109 }
109 }110 }
@@ -154,7 +155,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
154 return err155 return err
155 }156 }
156 157
157- _, err = fmt.Fprintf(out, "Registry: would keep last %d tags per repository\n", keepLast)158+ _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
158 return err159 return err
159 }160 }
160 161
@@ -187,6 +188,10 @@ func formatImageCreatedAt(t time.Time) string {
187 }188 }
188 189
189 func formatImageSize(size int64) string {190 func formatImageSize(size int64) string {
191+ if size <= 0 {
192+ return "-"
193+ }
194+
190 const unit = 1024195 const unit = 1024
191 if size < unit {196 if size < unit {
192 return fmt.Sprintf("%d B", size)197 return fmt.Sprintf("%d B", size)
@@ -198,3 +203,11 @@ func formatImageSize(size int64) string {
198 }203 }
199 return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])204 return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
200 }205 }
206+
207+func formatImageID(id string) string {
208+ if strings.TrimSpace(id) == "" {
209+ return "-"
210+ }
211+
212+ return id
213+}
M internal/adapters/in/cli/images_test.go +1 -1
@@ -87,7 +87,7 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
8787 text := out.String()
8888 assert.Contains(t, text, "Dry run")
8989 assert.Contains(t, text, "would prune 2 dangling runtime images")
90- assert.Contains(t, text, "would keep last 3 tags")
90+ assert.Contains(t, text, "would keep latest + 3 previous tags")
9191 assert.Equal(t, 1, client.listImagesCalls)
9292 assert.Equal(t, 0, client.pruneCalls)
9393 }
@@ -87,7 +87,7 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
87 text := out.String()87 text := out.String()
88 assert.Contains(t, text, "Dry run")88 assert.Contains(t, text, "Dry run")
89 assert.Contains(t, text, "would prune 2 dangling runtime images")89 assert.Contains(t, text, "would prune 2 dangling runtime images")
90- assert.Contains(t, text, "would keep last 3 tags")90+ assert.Contains(t, text, "would keep latest + 3 previous tags")
91 assert.Equal(t, 1, client.listImagesCalls)91 assert.Equal(t, 1, client.listImagesCalls)
92 assert.Equal(t, 0, client.pruneCalls)92 assert.Equal(t, 0, client.pruneCalls)
93 }93 }
M internal/adapters/in/cli/remote/client.go +1 -1
@@ -485,7 +485,7 @@ type RouteHealth struct {
485485
486486 // Images API
487487
488-// ListImages returns runtime images from the admin API.
488+// ListImages returns runtime images and registry tags from the admin API.
489489 func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
490490 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
491491 if err != nil {
@@ -485,7 +485,7 @@ type RouteHealth struct {
485 485
486 // Images API486 // Images API
487 487
488-// ListImages returns runtime images from the admin API.488+// ListImages returns runtime images and registry tags from the admin API.
489 func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {489 func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
490 resp, err := c.request(ctx, http.MethodGet, "/images", nil)490 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
491 if err != nil {491 if err != nil {
M internal/adapters/in/cli/rollback.go +51 -1
@@ -26,7 +26,8 @@ Tags are read from the Gordon registry.
2626
2727 Examples:
2828 gordon rollback myapp.example.com --remote ...
29- gordon rollback myapp.example.com --tag v1.0.0 --remote ...`,
29+ gordon rollback myapp.example.com --tag v1.0.0 --remote ...
30+ gordon rollback list myapp.example.com --remote ...`,
3031 Args: cobra.ExactArgs(1),
3132 RunE: func(cmd *cobra.Command, args []string) error {
3233 return runRollback(cmd.Context(), args[0], targetTag)
@@ -34,10 +35,22 @@ Examples:
3435 }
3536
3637 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
38+ cmd.AddCommand(newRollbackListCmd())
3739
3840 return cmd
3941 }
4042
43+func newRollbackListCmd() *cobra.Command {
44+ return &cobra.Command{
45+ Use: "list <domain>",
46+ Short: "List available rollback tags",
47+ Args: cobra.ExactArgs(1),
48+ RunE: func(cmd *cobra.Command, args []string) error {
49+ return runRollbackList(cmd.Context(), args[0])
50+ },
51+ }
52+}
53+
4154 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
4255 handle, err := resolveControlPlane(configPath)
4356 if err != nil {
@@ -73,6 +86,43 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
7386 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
7487 }
7588
89+func runRollbackList(ctx context.Context, rollbackDomain string) error {
90+ handle, err := resolveControlPlane(configPath)
91+ if err != nil {
92+ return err
93+ }
94+ defer handle.close()
95+
96+ route, err := handle.plane.GetRoute(ctx, rollbackDomain)
97+ if err != nil {
98+ return fmt.Errorf("failed to get route: %w", err)
99+ }
100+
101+ _, imageName, currentTag := parseImageRef(route.Image)
102+ if imageName == "" {
103+ return fmt.Errorf("cannot parse image name from route: %s", route.Image)
104+ }
105+
106+ tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
107+ if err != nil {
108+ return err
109+ }
110+
111+ printRollbackTags(rollbackDomain, currentTag, tags)
112+ return nil
113+}
114+
115+func printRollbackTags(rollbackDomain, currentTag string, tags []string) {
116+ fmt.Printf("Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain))
117+ for _, tag := range tags {
118+ suffix := ""
119+ if tag == currentTag {
120+ suffix = " (current)"
121+ }
122+ fmt.Printf("- %s%s\n", tag, suffix)
123+ }
124+}
125+
76126 func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
77127 tags, err := cp.ListTags(ctx, imageName)
78128 if err != nil {
@@ -26,7 +26,8 @@ Tags are read from the Gordon registry.
26 26
27 Examples:27 Examples:
28 gordon rollback myapp.example.com --remote ...28 gordon rollback myapp.example.com --remote ...
29- gordon rollback myapp.example.com --tag v1.0.0 --remote ...`,29+ gordon rollback myapp.example.com --tag v1.0.0 --remote ...
30+ gordon rollback list myapp.example.com --remote ...`,
30 Args: cobra.ExactArgs(1),31 Args: cobra.ExactArgs(1),
31 RunE: func(cmd *cobra.Command, args []string) error {32 RunE: func(cmd *cobra.Command, args []string) error {
32 return runRollback(cmd.Context(), args[0], targetTag)33 return runRollback(cmd.Context(), args[0], targetTag)
@@ -34,10 +35,22 @@ Examples:
34 }35 }
35 36
36 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")37 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
38+ cmd.AddCommand(newRollbackListCmd())
37 39
38 return cmd40 return cmd
39 }41 }
40 42
43+func newRollbackListCmd() *cobra.Command {
44+ return &cobra.Command{
45+ Use: "list <domain>",
46+ Short: "List available rollback tags",
47+ Args: cobra.ExactArgs(1),
48+ RunE: func(cmd *cobra.Command, args []string) error {
49+ return runRollbackList(cmd.Context(), args[0])
50+ },
51+ }
52+}
53+
41 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {54 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
42 handle, err := resolveControlPlane(configPath)55 handle, err := resolveControlPlane(configPath)
43 if err != nil {56 if err != nil {
@@ -73,6 +86,43 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
73 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)86 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
74 }87 }
75 88
89+func runRollbackList(ctx context.Context, rollbackDomain string) error {
90+ handle, err := resolveControlPlane(configPath)
91+ if err != nil {
92+ return err
93+ }
94+ defer handle.close()
95+
96+ route, err := handle.plane.GetRoute(ctx, rollbackDomain)
97+ if err != nil {
98+ return fmt.Errorf("failed to get route: %w", err)
99+ }
100+
101+ _, imageName, currentTag := parseImageRef(route.Image)
102+ if imageName == "" {
103+ return fmt.Errorf("cannot parse image name from route: %s", route.Image)
104+ }
105+
106+ tags, err := fetchAndSortTags(ctx, handle.plane, imageName)
107+ if err != nil {
108+ return err
109+ }
110+
111+ printRollbackTags(rollbackDomain, currentTag, tags)
112+ return nil
113+}
114+
115+func printRollbackTags(rollbackDomain, currentTag string, tags []string) {
116+ fmt.Printf("Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain))
117+ for _, tag := range tags {
118+ suffix := ""
119+ if tag == currentTag {
120+ suffix = " (current)"
121+ }
122+ fmt.Printf("- %s%s\n", tag, suffix)
123+ }
124+}
125+
76 func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {126 func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
77 tags, err := cp.ListTags(ctx, imageName)127 tags, err := cp.ListTags(ctx, imageName)
78 if err != nil {128 if err != nil {
M internal/boundaries/in/images.go +1 -1
@@ -9,7 +9,7 @@ import (
99
1010 // ImageService defines image listing and prune operations.
1111 type ImageService interface {
12- // ListImages returns runtime images.
12+ // ListImages returns runtime images and known registry tags.
1313 ListImages(ctx context.Context) ([]domain.ImageInfo, error)
1414
1515 // Prune removes unused images and applies retention.
@@ -9,7 +9,7 @@ import (
9 9
10 // ImageService defines image listing and prune operations.10 // ImageService defines image listing and prune operations.
11 type ImageService interface {11 type ImageService interface {
12- // ListImages returns runtime images.12+ // ListImages returns runtime images and known registry tags.
13 ListImages(ctx context.Context) ([]domain.ImageInfo, error)13 ListImages(ctx context.Context) ([]domain.ImageInfo, error)
14 14
15 // Prune removes unused images and applies retention.15 // Prune removes unused images and applies retention.
M internal/domain/images.go +3 -2
@@ -8,7 +8,8 @@ const DefaultImagePruneKeepLast = 3
88 type ImagePruneConfig struct {
99 Enabled bool
1010 Schedule BackupSchedule
11- // KeepLast is the number of most-recent tags to retain per repository.
11+ // KeepLast is the number of most-recent non-latest tags to retain per repository.
12+ // The latest tag is retained separately when present.
1213 KeepLast int
1314 }
1415
@@ -33,7 +34,7 @@ type ImagePruneReport struct {
3334 Registry RegistryPruneResult
3435 }
3536
36-// ImageInfo describes a runtime image.
37+// ImageInfo describes an image/tag visible from runtime and registry data.
3738 type ImageInfo struct {
3839 Repository string
3940 Tag string
@@ -8,7 +8,8 @@ const DefaultImagePruneKeepLast = 3
8 type ImagePruneConfig struct {8 type ImagePruneConfig struct {
9 Enabled bool9 Enabled bool
10 Schedule BackupSchedule10 Schedule BackupSchedule
11- // KeepLast is the number of most-recent tags to retain per repository.11+ // KeepLast is the number of most-recent non-latest tags to retain per repository.
12+ // The latest tag is retained separately when present.
12 KeepLast int13 KeepLast int
13 }14 }
14 15
@@ -33,7 +34,7 @@ type ImagePruneReport struct {
33 Registry RegistryPruneResult34 Registry RegistryPruneResult
34 }35 }
35 36
36-// ImageInfo describes a runtime image.37+// ImageInfo describes an image/tag visible from runtime and registry data.
37 type ImageInfo struct {38 type ImageInfo struct {
38 Repository string39 Repository string
39 Tag string40 Tag string
M internal/usecase/images/service.go +122 -5
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/gordon/internal/boundaries/out"
1414 "github.com/bnema/gordon/internal/domain"
1515 "github.com/bnema/gordon/pkg/runtime"
16+ "github.com/bnema/gordon/pkg/validation"
1617 )
1718
1819 // Service implements image list and prune operations.
@@ -43,7 +44,7 @@ func NewService(
4344 }
4445 }
4546
46-// ListImages returns images known by the runtime.
47+// ListImages returns images known by the runtime and registry tags.
4748 func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
4849 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
4950 zerowrap.FieldLayer: "usecase",
@@ -57,6 +58,8 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
5758 }
5859
5960 images := make([]domain.ImageInfo, 0, len(details))
61+ seenRepoTags := make(map[string]struct{}, len(details))
62+ repoDisplayByNormalized := make(map[string]string)
6063 for _, detail := range details {
6164 if isDanglingImage(detail.RepoTags) {
6265 images = append(images, domain.ImageInfo{
@@ -75,6 +78,11 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
7578 continue
7679 }
7780 repository, tag := splitRepoTag(repoTag)
81+ normalizedRepository := normalizeRepository(repository)
82+ seenRepoTags[repoTagKey(normalizedRepository, tag)] = struct{}{}
83+ if normalizedRepository != "" && normalizedRepository != repository {
84+ repoDisplayByNormalized[normalizedRepository] = repository
85+ }
7886 images = append(images, domain.ImageInfo{
7987 Repository: repository,
8088 Tag: tag,
@@ -86,6 +94,57 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
8694 }
8795 }
8896
97+ repositories, err := s.manifestStorage.ListRepositories()
98+ if err != nil {
99+ return nil, log.WrapErr(err, "failed to list repositories")
100+ }
101+
102+ sort.Strings(repositories)
103+ for _, repository := range repositories {
104+ tags, err := s.manifestStorage.ListTags(repository)
105+ if err != nil {
106+ return nil, log.WrapErr(err, "failed to list repository tags")
107+ }
108+
109+ displayRepository := repository
110+ normalizedRepository := normalizeRepository(repository)
111+ if mappedRepository, ok := repoDisplayByNormalized[normalizedRepository]; ok {
112+ displayRepository = mappedRepository
113+ }
114+
115+ for _, tag := range tags {
116+ if isRegistryTagPlaceholder(tag) {
117+ continue
118+ }
119+
120+ key := repoTagKey(normalizedRepository, tag)
121+ if _, exists := seenRepoTags[key]; exists {
122+ continue
123+ }
124+
125+ createdAt, err := s.manifestStorage.GetManifestModTime(repository, tag)
126+ if err != nil {
127+ log.Warn().
128+ Err(err).
129+ Str("repository", repository).
130+ Str("tag", tag).
131+ Msg("failed to read manifest modification time")
132+ }
133+
134+ images = append(images, domain.ImageInfo{
135+ Repository: displayRepository,
136+ Tag: tag,
137+ Created: createdAt,
138+ Dangling: false,
139+ })
140+ seenRepoTags[key] = struct{}{}
141+ }
142+ }
143+
144+ sort.SliceStable(images, func(i, j int) bool {
145+ return lessImageInfo(images[i], images[j])
146+ })
147+
89148 return images, nil
90149 }
91150
@@ -111,8 +170,8 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
111170 }
112171
113172 // PruneRegistry applies tag retention and blob garbage collection.
114-// It keeps the most recent keepLast tags from tagInfos and always keeps
115-// the "latest" tag when present, so up to keepLast+1 tags can be retained.
173+// It keeps the "latest" tag when present and keeps keepLast most-recent
174+// non-latest tags from tagInfos.
116175 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
117176 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
118177 zerowrap.FieldLayer: "usecase",
@@ -203,8 +262,16 @@ func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
203262 keptTags["latest"] = struct{}{}
204263 }
205264
206- for i := 0; i < len(tagInfos) && i < keepLast; i++ {
207- keptTags[tagInfos[i].name] = struct{}{}
265+ kept := 0
266+ for _, tagInfo := range tagInfos {
267+ if tagInfo.name == "latest" {
268+ continue
269+ }
270+ if kept >= keepLast {
271+ break
272+ }
273+ keptTags[tagInfo.name] = struct{}{}
274+ kept++
208275 }
209276
210277 return keptTags
@@ -401,3 +468,53 @@ func splitRepoTag(repoTag string) (string, string) {
401468
402469 return repoTag[:idx], repoTag[idx+1:]
403470 }
471+
472+func normalizeRepository(repository string) string {
473+ repository = strings.TrimSpace(repository)
474+ if repository == "" {
475+ return ""
476+ }
477+
478+ idx := strings.Index(repository, "/")
479+ if idx <= 0 {
480+ return repository
481+ }
482+
483+ firstComponent := repository[:idx]
484+ if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" {
485+ return repository[idx+1:]
486+ }
487+
488+ return repository
489+}
490+
491+func repoTagKey(repository, tag string) string {
492+ return repository + "\x00" + tag
493+}
494+
495+func isRegistryTagPlaceholder(tag string) bool {
496+ return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag)
497+}
498+
499+func lessImageInfo(left, right domain.ImageInfo) bool {
500+ if left.Dangling != right.Dangling {
501+ return !left.Dangling
502+ }
503+ if left.Repository != right.Repository {
504+ return left.Repository < right.Repository
505+ }
506+ if left.Tag == "latest" && right.Tag != "latest" {
507+ return true
508+ }
509+ if right.Tag == "latest" && left.Tag != "latest" {
510+ return false
511+ }
512+ if !left.Created.Equal(right.Created) {
513+ return left.Created.After(right.Created)
514+ }
515+ if left.Tag != right.Tag {
516+ return left.Tag > right.Tag
517+ }
518+
519+ return left.ID < right.ID
520+}
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/gordon/internal/boundaries/out"13 "github.com/bnema/gordon/internal/boundaries/out"
14 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/pkg/runtime"15 "github.com/bnema/gordon/pkg/runtime"
16+ "github.com/bnema/gordon/pkg/validation"
16 )17 )
17 18
18 // Service implements image list and prune operations.19 // Service implements image list and prune operations.
@@ -43,7 +44,7 @@ func NewService(
43 }44 }
44 }45 }
45 46
46-// ListImages returns images known by the runtime.47+// ListImages returns images known by the runtime and registry tags.
47 func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {48 func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
48 ctx = zerowrap.CtxWithFields(ctx, map[string]any{49 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
49 zerowrap.FieldLayer: "usecase",50 zerowrap.FieldLayer: "usecase",
@@ -57,6 +58,8 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
57 }58 }
58 59
59 images := make([]domain.ImageInfo, 0, len(details))60 images := make([]domain.ImageInfo, 0, len(details))
61+ seenRepoTags := make(map[string]struct{}, len(details))
62+ repoDisplayByNormalized := make(map[string]string)
60 for _, detail := range details {63 for _, detail := range details {
61 if isDanglingImage(detail.RepoTags) {64 if isDanglingImage(detail.RepoTags) {
62 images = append(images, domain.ImageInfo{65 images = append(images, domain.ImageInfo{
@@ -75,6 +78,11 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
75 continue78 continue
76 }79 }
77 repository, tag := splitRepoTag(repoTag)80 repository, tag := splitRepoTag(repoTag)
81+ normalizedRepository := normalizeRepository(repository)
82+ seenRepoTags[repoTagKey(normalizedRepository, tag)] = struct{}{}
83+ if normalizedRepository != "" && normalizedRepository != repository {
84+ repoDisplayByNormalized[normalizedRepository] = repository
85+ }
78 images = append(images, domain.ImageInfo{86 images = append(images, domain.ImageInfo{
79 Repository: repository,87 Repository: repository,
80 Tag: tag,88 Tag: tag,
@@ -86,6 +94,57 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
86 }94 }
87 }95 }
88 96
97+ repositories, err := s.manifestStorage.ListRepositories()
98+ if err != nil {
99+ return nil, log.WrapErr(err, "failed to list repositories")
100+ }
101+
102+ sort.Strings(repositories)
103+ for _, repository := range repositories {
104+ tags, err := s.manifestStorage.ListTags(repository)
105+ if err != nil {
106+ return nil, log.WrapErr(err, "failed to list repository tags")
107+ }
108+
109+ displayRepository := repository
110+ normalizedRepository := normalizeRepository(repository)
111+ if mappedRepository, ok := repoDisplayByNormalized[normalizedRepository]; ok {
112+ displayRepository = mappedRepository
113+ }
114+
115+ for _, tag := range tags {
116+ if isRegistryTagPlaceholder(tag) {
117+ continue
118+ }
119+
120+ key := repoTagKey(normalizedRepository, tag)
121+ if _, exists := seenRepoTags[key]; exists {
122+ continue
123+ }
124+
125+ createdAt, err := s.manifestStorage.GetManifestModTime(repository, tag)
126+ if err != nil {
127+ log.Warn().
128+ Err(err).
129+ Str("repository", repository).
130+ Str("tag", tag).
131+ Msg("failed to read manifest modification time")
132+ }
133+
134+ images = append(images, domain.ImageInfo{
135+ Repository: displayRepository,
136+ Tag: tag,
137+ Created: createdAt,
138+ Dangling: false,
139+ })
140+ seenRepoTags[key] = struct{}{}
141+ }
142+ }
143+
144+ sort.SliceStable(images, func(i, j int) bool {
145+ return lessImageInfo(images[i], images[j])
146+ })
147+
89 return images, nil148 return images, nil
90 }149 }
91 150
@@ -111,8 +170,8 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
111 }170 }
112 171
113 // PruneRegistry applies tag retention and blob garbage collection.172 // PruneRegistry applies tag retention and blob garbage collection.
114-// It keeps the most recent keepLast tags from tagInfos and always keeps173+// It keeps the "latest" tag when present and keeps keepLast most-recent
115-// the "latest" tag when present, so up to keepLast+1 tags can be retained.174+// non-latest tags from tagInfos.
116 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {175 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
117 ctx = zerowrap.CtxWithFields(ctx, map[string]any{176 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
118 zerowrap.FieldLayer: "usecase",177 zerowrap.FieldLayer: "usecase",
@@ -203,8 +262,16 @@ func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
203 keptTags["latest"] = struct{}{}262 keptTags["latest"] = struct{}{}
204 }263 }
205 264
206- for i := 0; i < len(tagInfos) && i < keepLast; i++ {265+ kept := 0
207- keptTags[tagInfos[i].name] = struct{}{}266+ for _, tagInfo := range tagInfos {
267+ if tagInfo.name == "latest" {
268+ continue
269+ }
270+ if kept >= keepLast {
271+ break
272+ }
273+ keptTags[tagInfo.name] = struct{}{}
274+ kept++
208 }275 }
209 276
210 return keptTags277 return keptTags
@@ -401,3 +468,53 @@ func splitRepoTag(repoTag string) (string, string) {
401 468
402 return repoTag[:idx], repoTag[idx+1:]469 return repoTag[:idx], repoTag[idx+1:]
403 }470 }
471+
472+func normalizeRepository(repository string) string {
473+ repository = strings.TrimSpace(repository)
474+ if repository == "" {
475+ return ""
476+ }
477+
478+ idx := strings.Index(repository, "/")
479+ if idx <= 0 {
480+ return repository
481+ }
482+
483+ firstComponent := repository[:idx]
484+ if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" {
485+ return repository[idx+1:]
486+ }
487+
488+ return repository
489+}
490+
491+func repoTagKey(repository, tag string) string {
492+ return repository + "\x00" + tag
493+}
494+
495+func isRegistryTagPlaceholder(tag string) bool {
496+ return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag)
497+}
498+
499+func lessImageInfo(left, right domain.ImageInfo) bool {
500+ if left.Dangling != right.Dangling {
501+ return !left.Dangling
502+ }
503+ if left.Repository != right.Repository {
504+ return left.Repository < right.Repository
505+ }
506+ if left.Tag == "latest" && right.Tag != "latest" {
507+ return true
508+ }
509+ if right.Tag == "latest" && left.Tag != "latest" {
510+ return false
511+ }
512+ if !left.Created.Equal(right.Created) {
513+ return left.Created.After(right.Created)
514+ }
515+ if left.Tag != right.Tag {
516+ return left.Tag > right.Tag
517+ }
518+
519+ return left.ID < right.ID
520+}
M internal/usecase/images/service_test.go +72 -14
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 func TestService_ListImages_ReturnsAllImages(t *testing.T) {
20- manifestStorage := noopManifestStorage{}
20+ manifestStorage := newFakeManifestStorage()
2121 blobStorage := noopBlobStorage{}
2222
2323 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
@@ -74,7 +74,7 @@ func TestService_ListImages_ReturnsAllImages(t *testing.T) {
7474 }
7575
7676 func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
77- manifestStorage := noopManifestStorage{}
77+ manifestStorage := newFakeManifestStorage()
7878 blobStorage := noopBlobStorage{}
7979 rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
8080
@@ -87,7 +87,7 @@ func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
8787 }
8888
8989 func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
90- manifestStorage := noopManifestStorage{}
90+ manifestStorage := newFakeManifestStorage()
9191 blobStorage := noopBlobStorage{}
9292 createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
9393
@@ -112,7 +112,7 @@ func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
112112 }
113113
114114 func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
115- manifestStorage := noopManifestStorage{}
115+ manifestStorage := newFakeManifestStorage()
116116 blobStorage := noopBlobStorage{}
117117 rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
118118
@@ -124,6 +124,60 @@ func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
124124 assert.Contains(t, err.Error(), "failed to list images")
125125 }
126126
127+func TestService_ListImages_IncludesRegistryTagsNotPresentInRuntime(t *testing.T) {
128+ manifestStorage := newFakeManifestStorage()
129+ manifestStorage.repositories = []string{"gordon/api"}
130+ manifestStorage.tagsByRepo["gordon/api"] = []string{"v1.2.3", "v1.2.2"}
131+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.2")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
132+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.3")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
133+
134+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
135+ rt := &fakeRuntime{
136+ listDetails: []pkgruntime.ImageDetail{
137+ {
138+ ID: "sha256:runtime",
139+ RepoTags: []string{"reg.bnema.dev/gordon/api:latest"},
140+ Size: 4096,
141+ Created: createdAt,
142+ },
143+ },
144+ }
145+
146+ svc := NewService(rt, manifestStorage, noopBlobStorage{}, zerowrap.Default())
147+
148+ images, err := svc.ListImages(context.Background())
149+
150+ require.NoError(t, err)
151+ require.Len(t, images, 3)
152+
153+ assert.Equal(t, domain.ImageInfo{
154+ Repository: "reg.bnema.dev/gordon/api",
155+ Tag: "latest",
156+ Size: 4096,
157+ Created: createdAt,
158+ ID: "sha256:runtime",
159+ Dangling: false,
160+ }, images[0])
161+
162+ assert.Equal(t, domain.ImageInfo{
163+ Repository: "reg.bnema.dev/gordon/api",
164+ Tag: "v1.2.3",
165+ Size: 0,
166+ Created: time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC),
167+ ID: "",
168+ Dangling: false,
169+ }, images[1])
170+
171+ assert.Equal(t, domain.ImageInfo{
172+ Repository: "reg.bnema.dev/gordon/api",
173+ Tag: "v1.2.2",
174+ Size: 0,
175+ Created: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC),
176+ ID: "",
177+ Dangling: false,
178+ }, images[2])
179+}
180+
127181 func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {
128182 manifestStorage := noopManifestStorage{}
129183 blobStorage := noopBlobStorage{}
@@ -191,6 +245,7 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
191245 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
192246 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
193247 manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
248+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
194249
195250 blobStorage := &fakeBlobStorage{}
196251 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
@@ -198,11 +253,8 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
198253 report, err := svc.PruneRegistry(context.Background(), 2)
199254
200255 require.NoError(t, err)
201- assert.Equal(t, 2, report.Registry.TagsRemoved)
202- assert.ElementsMatch(t, []manifestRef{
203- {name: "gordon/api", reference: "v2"},
204- {name: "gordon/api", reference: "v1"},
205- }, manifestStorage.deletedManifests)
256+ assert.Equal(t, 1, report.Registry.TagsRemoved)
257+ assert.ElementsMatch(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
206258 }
207259
208260 func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
@@ -279,13 +331,15 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
279331 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
280332 manifestStorage := newFakeManifestStorage()
281333 manifestStorage.repositories = []string{"gordon/api"}
282- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
334+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
283335 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
336+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
284337 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
285338 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
339+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-shared")
286340 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
287341
288- blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}}
342+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:cfg-v2", "sha256:layer-shared", "sha256:cfg-v1"}}
289343 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
290344
291345 report, err := svc.PruneRegistry(context.Background(), 1)
@@ -376,7 +430,7 @@ func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
376430 blobStorage := &fakeBlobStorage{}
377431 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
378432
379- report, err := svc.PruneRegistry(context.Background(), 2)
433+ report, err := svc.PruneRegistry(context.Background(), 1)
380434
381435 require.NoError(t, err)
382436 assert.Equal(t, 2, report.Registry.TagsRemoved)
@@ -404,10 +458,12 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
404458 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
405459 manifestStorage := newFakeManifestStorage()
406460 manifestStorage.repositories = []string{"gordon/api"}
407- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
461+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
408462 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
463+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
409464 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
410465 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
466+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
411467 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
412468 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
413469
@@ -427,10 +483,12 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
427483 func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
428484 manifestStorage := newFakeManifestStorage()
429485 manifestStorage.repositories = []string{"gordon/api"}
430- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
486+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
431487 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
488+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
432489 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
433490 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
491+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
434492 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
435493 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
436494
@@ -17,7 +17,7 @@ import (
17 )17 )
18 18
19 func TestService_ListImages_ReturnsAllImages(t *testing.T) {19 func TestService_ListImages_ReturnsAllImages(t *testing.T) {
20- manifestStorage := noopManifestStorage{}20+ manifestStorage := newFakeManifestStorage()
21 blobStorage := noopBlobStorage{}21 blobStorage := noopBlobStorage{}
22 22
23 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)23 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
@@ -74,7 +74,7 @@ func TestService_ListImages_ReturnsAllImages(t *testing.T) {
74 }74 }
75 75
76 func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {76 func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
77- manifestStorage := noopManifestStorage{}77+ manifestStorage := newFakeManifestStorage()
78 blobStorage := noopBlobStorage{}78 blobStorage := noopBlobStorage{}
79 rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}79 rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
80 80
@@ -87,7 +87,7 @@ func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
87 }87 }
88 88
89 func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {89 func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
90- manifestStorage := noopManifestStorage{}90+ manifestStorage := newFakeManifestStorage()
91 blobStorage := noopBlobStorage{}91 blobStorage := noopBlobStorage{}
92 createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)92 createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
93 93
@@ -112,7 +112,7 @@ func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
112 }112 }
113 113
114 func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {114 func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
115- manifestStorage := noopManifestStorage{}115+ manifestStorage := newFakeManifestStorage()
116 blobStorage := noopBlobStorage{}116 blobStorage := noopBlobStorage{}
117 rt := &fakeRuntime{listErr: errors.New("runtime list failed")}117 rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
118 118
@@ -124,6 +124,60 @@ func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
124 assert.Contains(t, err.Error(), "failed to list images")124 assert.Contains(t, err.Error(), "failed to list images")
125 }125 }
126 126
127+func TestService_ListImages_IncludesRegistryTagsNotPresentInRuntime(t *testing.T) {
128+ manifestStorage := newFakeManifestStorage()
129+ manifestStorage.repositories = []string{"gordon/api"}
130+ manifestStorage.tagsByRepo["gordon/api"] = []string{"v1.2.3", "v1.2.2"}
131+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.2")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
132+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.3")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
133+
134+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
135+ rt := &fakeRuntime{
136+ listDetails: []pkgruntime.ImageDetail{
137+ {
138+ ID: "sha256:runtime",
139+ RepoTags: []string{"reg.bnema.dev/gordon/api:latest"},
140+ Size: 4096,
141+ Created: createdAt,
142+ },
143+ },
144+ }
145+
146+ svc := NewService(rt, manifestStorage, noopBlobStorage{}, zerowrap.Default())
147+
148+ images, err := svc.ListImages(context.Background())
149+
150+ require.NoError(t, err)
151+ require.Len(t, images, 3)
152+
153+ assert.Equal(t, domain.ImageInfo{
154+ Repository: "reg.bnema.dev/gordon/api",
155+ Tag: "latest",
156+ Size: 4096,
157+ Created: createdAt,
158+ ID: "sha256:runtime",
159+ Dangling: false,
160+ }, images[0])
161+
162+ assert.Equal(t, domain.ImageInfo{
163+ Repository: "reg.bnema.dev/gordon/api",
164+ Tag: "v1.2.3",
165+ Size: 0,
166+ Created: time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC),
167+ ID: "",
168+ Dangling: false,
169+ }, images[1])
170+
171+ assert.Equal(t, domain.ImageInfo{
172+ Repository: "reg.bnema.dev/gordon/api",
173+ Tag: "v1.2.2",
174+ Size: 0,
175+ Created: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC),
176+ ID: "",
177+ Dangling: false,
178+ }, images[2])
179+}
180+
127 func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {181 func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {
128 manifestStorage := noopManifestStorage{}182 manifestStorage := noopManifestStorage{}
129 blobStorage := noopBlobStorage{}183 blobStorage := noopBlobStorage{}
@@ -191,6 +245,7 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
191 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)245 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
192 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")246 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
193 manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")247 manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
248+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
194 249
195 blobStorage := &fakeBlobStorage{}250 blobStorage := &fakeBlobStorage{}
196 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())251 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
@@ -198,11 +253,8 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
198 report, err := svc.PruneRegistry(context.Background(), 2)253 report, err := svc.PruneRegistry(context.Background(), 2)
199 254
200 require.NoError(t, err)255 require.NoError(t, err)
201- assert.Equal(t, 2, report.Registry.TagsRemoved)256+ assert.Equal(t, 1, report.Registry.TagsRemoved)
202- assert.ElementsMatch(t, []manifestRef{257+ assert.ElementsMatch(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
203- {name: "gordon/api", reference: "v2"},
204- {name: "gordon/api", reference: "v1"},
205- }, manifestStorage.deletedManifests)
206 }258 }
207 259
208 func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {260 func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
@@ -279,13 +331,15 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
279 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {331 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
280 manifestStorage := newFakeManifestStorage()332 manifestStorage := newFakeManifestStorage()
281 manifestStorage.repositories = []string{"gordon/api"}333 manifestStorage.repositories = []string{"gordon/api"}
282- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}334+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
283 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)335 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
336+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
284 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)337 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
285 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")338 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
339+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-shared")
286 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")340 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
287 341
288- blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}}342+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:cfg-v2", "sha256:layer-shared", "sha256:cfg-v1"}}
289 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())343 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
290 344
291 report, err := svc.PruneRegistry(context.Background(), 1)345 report, err := svc.PruneRegistry(context.Background(), 1)
@@ -376,7 +430,7 @@ func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
376 blobStorage := &fakeBlobStorage{}430 blobStorage := &fakeBlobStorage{}
377 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())431 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
378 432
379- report, err := svc.PruneRegistry(context.Background(), 2)433+ report, err := svc.PruneRegistry(context.Background(), 1)
380 434
381 require.NoError(t, err)435 require.NoError(t, err)
382 assert.Equal(t, 2, report.Registry.TagsRemoved)436 assert.Equal(t, 2, report.Registry.TagsRemoved)
@@ -404,10 +458,12 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
404 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {458 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
405 manifestStorage := newFakeManifestStorage()459 manifestStorage := newFakeManifestStorage()
406 manifestStorage.repositories = []string{"gordon/api"}460 manifestStorage.repositories = []string{"gordon/api"}
407- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}461+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
408 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)462 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
463+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
409 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)464 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
410 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")465 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
466+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
411 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}467 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
412 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}468 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
413 469
@@ -427,10 +483,12 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
427 func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {483 func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
428 manifestStorage := newFakeManifestStorage()484 manifestStorage := newFakeManifestStorage()
429 manifestStorage.repositories = []string{"gordon/api"}485 manifestStorage.repositories = []string{"gordon/api"}
430- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}486+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
431 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)487 manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
488+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
432 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)489 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
433 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")490 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
491+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
434 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}492 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
435 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}493 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
436 494

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 7

Tags 106

↑↓ moveenter openesc close