detached 7568a1c

Merge pull request #147 from bnema/feat/preview-orphan-gc

feat: preview orphan GC and ticker interval reduction
commit
7568a1c19dad28b5efb68f998bb67c326ad2fdae
author
bnema · · 2026-03-25 12:51 UTC
committer
GitHub · · 2026-03-25 12:51 UTC
merge of
e46f9aa + 9209a14 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.30.0-pre5
8 files · +494 -24
M internal/adapters/out/docker/runtime.go +4 -0
@@ -364,6 +364,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
364364 Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
365365 Ports: ports,
366366 Labels: c.Labels,
367+ Created: time.Unix(c.Created, 0),
367368 })
368369 }
369370
@@ -402,6 +403,8 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
402403 // Get container name (remove leading slash)
403404 name := strings.TrimPrefix(resp.Name, "/")
404405
406+ created, _ := time.Parse(time.RFC3339Nano, resp.Created)
407+
405408 return &domain.Container{
406409 ID: resp.ID,
407410 Image: resp.Config.Image,
@@ -411,6 +414,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
411414 ExitCode: resp.State.ExitCode,
412415 Ports: ports,
413416 Labels: resp.Config.Labels,
417+ Created: created,
414418 }, nil
415419 }
416420
@@ -364,6 +364,7 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
364 Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")364 Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
365 Ports: ports,365 Ports: ports,
366 Labels: c.Labels,366 Labels: c.Labels,
367+ Created: time.Unix(c.Created, 0),
367 })368 })
368 }369 }
369 370
@@ -402,6 +403,8 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
402 // Get container name (remove leading slash)403 // Get container name (remove leading slash)
403 name := strings.TrimPrefix(resp.Name, "/")404 name := strings.TrimPrefix(resp.Name, "/")
404 405
406+ created, _ := time.Parse(time.RFC3339Nano, resp.Created)
407+
405 return &domain.Container{408 return &domain.Container{
406 ID: resp.ID,409 ID: resp.ID,
407 Image: resp.Config.Image,410 Image: resp.Config.Image,
@@ -411,6 +414,7 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
411 ExitCode: resp.State.ExitCode,414 ExitCode: resp.State.ExitCode,
412 Ports: ports,415 Ports: ports,
413 Labels: resp.Config.Labels,416 Labels: resp.Config.Labels,
417+ Created: created,
414 }, nil418 }, nil
415 }419 }
416 420
M internal/app/run.go +131 -21
@@ -587,31 +587,141 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
587587 log.Warn().Err(err).Msg("failed to load previews")
588588 }
589589
590- svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) {
591- log := zerowrap.FromCtx(ctx)
592- for _, containerName := range p.Containers {
593- if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
594- log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to stop preview container")
595- }
596- if err := svc.runtime.RemoveContainer(ctx, containerName, true); err != nil {
597- log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to remove preview container")
598- }
590+ // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute.
591+ sweepInterval := previewConfig.TTL / 2
592+ if sweepInterval > 1*time.Hour {
593+ sweepInterval = 1 * time.Hour
594+ }
595+ if sweepInterval < 1*time.Minute {
596+ sweepInterval = 1 * time.Minute
597+ }
598+
599+ svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
600+ teardownTrackedPreview(ctx, svc, p)
601+ }, func(ctx context.Context) {
602+ gcOrphanedPreviews(ctx, svc, previewConfig)
603+ })
604+}
605+
606+// teardownTrackedPreview removes all resources for a tracked preview that has expired.
607+func teardownTrackedPreview(ctx context.Context, svc *services, p domain.PreviewRoute) {
608+ log := zerowrap.FromCtx(ctx)
609+ for _, containerName := range p.Containers {
610+ if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
611+ log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to stop preview container")
599612 }
600- for _, volName := range p.Volumes {
601- if err := svc.runtime.RemoveVolume(ctx, volName, true); err != nil {
602- log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
603- }
613+ if err := svc.runtime.RemoveContainer(ctx, containerName, true); err != nil {
614+ log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to remove preview container")
615+ }
616+ }
617+ for _, volName := range p.Volumes {
618+ if err := svc.runtime.RemoveVolume(ctx, volName, true); err != nil {
619+ log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
604620 }
605- // Remove route from config so proxy stops routing to this domain
606- if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
607- if errors.Is(err, domain.ErrRouteNotFound) {
608- log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
609- } else {
610- log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
621+ }
622+ // Remove network (naming convention: {networkPrefix}-{domain-sanitized}).
623+ networkPrefix := svc.configSvc.GetNetworkPrefix()
624+ networkName := networkPrefix + "-" + strings.ReplaceAll(p.Domain, ".", "-")
625+ if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
626+ log.Warn().Err(err).Str("network", networkName).Str("preview", p.Domain).Msg("failed to remove preview network")
627+ }
628+ // Remove route from config so proxy stops routing to this domain.
629+ if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
630+ if errors.Is(err, domain.ErrRouteNotFound) {
631+ log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
632+ } else {
633+ log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
634+ }
635+ }
636+ svc.proxySvc.InvalidateTarget(ctx, p.Domain)
637+}
638+
639+// gcOrphanedPreviews finds and tears down untracked preview containers.
640+func gcOrphanedPreviews(ctx context.Context, svc *services, previewConfig domain.PreviewConfig) {
641+ log := zerowrap.FromCtx(ctx)
642+ orphans := svc.previewService.CollectOrphans(ctx, svc.runtime, previewConfig.TagPatterns, previewConfig.Separator)
643+ for _, c := range orphans {
644+ orphanDomain := c.Labels[domain.LabelDomain]
645+ log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
646+ Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
647+
648+ // Stop container first, then clean up domain resources while container
649+ // still exists (so future scans can rediscover it on failure), then remove.
650+ if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
651+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
652+ }
653+
654+ if orphanDomain != "" {
655+ if err := cleanupOrphanDomainResources(ctx, svc, orphanDomain); err != nil {
656+ log.Warn().Err(err).Str("container", c.Name).Str("domain", orphanDomain).
657+ Msg("orphan resource cleanup failed, deferring container removal to next scan")
658+ continue
611659 }
612660 }
613- svc.proxySvc.InvalidateTarget(ctx, p.Domain)
614- })
661+
662+ if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
663+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
664+ }
665+
666+ log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
667+ }
668+}
669+
670+// cleanupOrphanDomainResources removes volumes, network, and route for an
671+// orphaned preview domain. Returns an error if any step fails so the caller
672+// can defer container removal until the next scan.
673+func cleanupOrphanDomainResources(ctx context.Context, svc *services, orphanDomain string) error {
674+ log := zerowrap.FromCtx(ctx)
675+ domainSanitized := strings.ReplaceAll(orphanDomain, ".", "-")
676+ var errs []error
677+
678+ errs = append(errs, removeOrphanVolumes(ctx, svc, domainSanitized, log)...)
679+ errs = append(errs, removeOrphanNetwork(ctx, svc, domainSanitized, log))
680+ errs = append(errs, removeOrphanRoute(ctx, svc, orphanDomain, log))
681+ svc.proxySvc.InvalidateTarget(ctx, orphanDomain)
682+
683+ return errors.Join(errs...)
684+}
685+
686+func removeOrphanVolumes(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) []error {
687+ _, volPrefix, _ := svc.configSvc.GetVolumeConfig()
688+ prefix := volPrefix + "-" + domainSanitized + "-"
689+
690+ volumes, err := svc.runtime.ListVolumes(ctx)
691+ if err != nil {
692+ log.Warn().Err(err).Msg("failed to list volumes for orphan cleanup")
693+ return []error{err}
694+ }
695+
696+ var errs []error
697+ for _, v := range volumes {
698+ if !strings.HasPrefix(v.Name, prefix) {
699+ continue
700+ }
701+ if err := svc.runtime.RemoveVolume(ctx, v.Name, true); err != nil {
702+ log.Warn().Err(err).Str("volume", v.Name).Msg("failed to remove orphan volume")
703+ errs = append(errs, err)
704+ }
705+ }
706+ return errs
707+}
708+
709+func removeOrphanNetwork(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) error {
710+ name := svc.configSvc.GetNetworkPrefix() + "-" + domainSanitized
711+ if err := svc.runtime.RemoveNetwork(ctx, name); err != nil {
712+ log.Warn().Err(err).Str("network", name).Msg("failed to remove orphan network")
713+ return err
714+ }
715+ return nil
716+}
717+
718+func removeOrphanRoute(ctx context.Context, svc *services, orphanDomain string, log zerowrap.Logger) error {
719+ err := svc.configSvc.RemoveRoute(ctx, orphanDomain)
720+ if err == nil || errors.Is(err, domain.ErrRouteNotFound) {
721+ return nil
722+ }
723+ log.Warn().Err(err).Str("domain", orphanDomain).Msg("failed to remove orphan route")
724+ return err
615725 }
616726
617727 // injectTelemetryMetrics creates and injects OTel metrics into services when
@@ -587,31 +587,141 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero
587 log.Warn().Err(err).Msg("failed to load previews")587 log.Warn().Err(err).Msg("failed to load previews")
588 }588 }
589 589
590- svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) {590+ // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute.
591- log := zerowrap.FromCtx(ctx)591+ sweepInterval := previewConfig.TTL / 2
592- for _, containerName := range p.Containers {592+ if sweepInterval > 1*time.Hour {
593- if err := svc.runtime.StopContainer(ctx, containerName); err != nil {593+ sweepInterval = 1 * time.Hour
594- log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to stop preview container")594+ }
595- }595+ if sweepInterval < 1*time.Minute {
596- if err := svc.runtime.RemoveContainer(ctx, containerName, true); err != nil {596+ sweepInterval = 1 * time.Minute
597- log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to remove preview container")597+ }
598- }598+
599+ svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) {
600+ teardownTrackedPreview(ctx, svc, p)
601+ }, func(ctx context.Context) {
602+ gcOrphanedPreviews(ctx, svc, previewConfig)
603+ })
604+}
605+
606+// teardownTrackedPreview removes all resources for a tracked preview that has expired.
607+func teardownTrackedPreview(ctx context.Context, svc *services, p domain.PreviewRoute) {
608+ log := zerowrap.FromCtx(ctx)
609+ for _, containerName := range p.Containers {
610+ if err := svc.runtime.StopContainer(ctx, containerName); err != nil {
611+ log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to stop preview container")
599 }612 }
600- for _, volName := range p.Volumes {613+ if err := svc.runtime.RemoveContainer(ctx, containerName, true); err != nil {
601- if err := svc.runtime.RemoveVolume(ctx, volName, true); err != nil {614+ log.Warn().Err(err).Str("container", containerName).Str("preview", p.Domain).Msg("failed to remove preview container")
602- log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")615+ }
603- }616+ }
617+ for _, volName := range p.Volumes {
618+ if err := svc.runtime.RemoveVolume(ctx, volName, true); err != nil {
619+ log.Warn().Err(err).Str("volume", volName).Str("preview", p.Domain).Msg("failed to remove preview volume")
604 }620 }
605- // Remove route from config so proxy stops routing to this domain621+ }
606- if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {622+ // Remove network (naming convention: {networkPrefix}-{domain-sanitized}).
607- if errors.Is(err, domain.ErrRouteNotFound) {623+ networkPrefix := svc.configSvc.GetNetworkPrefix()
608- log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")624+ networkName := networkPrefix + "-" + strings.ReplaceAll(p.Domain, ".", "-")
609- } else {625+ if err := svc.runtime.RemoveNetwork(ctx, networkName); err != nil {
610- log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")626+ log.Warn().Err(err).Str("network", networkName).Str("preview", p.Domain).Msg("failed to remove preview network")
627+ }
628+ // Remove route from config so proxy stops routing to this domain.
629+ if err := svc.configSvc.RemoveRoute(ctx, p.Domain); err != nil {
630+ if errors.Is(err, domain.ErrRouteNotFound) {
631+ log.Debug().Str("domain", p.Domain).Msg("preview route already removed from config")
632+ } else {
633+ log.Warn().Err(err).Str("domain", p.Domain).Msg("failed to remove preview route from config")
634+ }
635+ }
636+ svc.proxySvc.InvalidateTarget(ctx, p.Domain)
637+}
638+
639+// gcOrphanedPreviews finds and tears down untracked preview containers.
640+func gcOrphanedPreviews(ctx context.Context, svc *services, previewConfig domain.PreviewConfig) {
641+ log := zerowrap.FromCtx(ctx)
642+ orphans := svc.previewService.CollectOrphans(ctx, svc.runtime, previewConfig.TagPatterns, previewConfig.Separator)
643+ for _, c := range orphans {
644+ orphanDomain := c.Labels[domain.LabelDomain]
645+ log.Warn().Str("container", c.Name).Str("image", c.Image).Str("domain", orphanDomain).
646+ Time("created", c.Created).Msg("orphaned preview container detected, cleaning up")
647+
648+ // Stop container first, then clean up domain resources while container
649+ // still exists (so future scans can rediscover it on failure), then remove.
650+ if err := svc.runtime.StopContainer(ctx, c.Name); err != nil {
651+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to stop orphan container")
652+ }
653+
654+ if orphanDomain != "" {
655+ if err := cleanupOrphanDomainResources(ctx, svc, orphanDomain); err != nil {
656+ log.Warn().Err(err).Str("container", c.Name).Str("domain", orphanDomain).
657+ Msg("orphan resource cleanup failed, deferring container removal to next scan")
658+ continue
611 }659 }
612 }660 }
613- svc.proxySvc.InvalidateTarget(ctx, p.Domain)661+
614- })662+ if err := svc.runtime.RemoveContainer(ctx, c.Name, true); err != nil {
663+ log.Warn().Err(err).Str("container", c.Name).Msg("failed to remove orphan container")
664+ }
665+
666+ log.Info().Str("container", c.Name).Str("domain", orphanDomain).Msg("orphaned preview container cleaned up")
667+ }
668+}
669+
670+// cleanupOrphanDomainResources removes volumes, network, and route for an
671+// orphaned preview domain. Returns an error if any step fails so the caller
672+// can defer container removal until the next scan.
673+func cleanupOrphanDomainResources(ctx context.Context, svc *services, orphanDomain string) error {
674+ log := zerowrap.FromCtx(ctx)
675+ domainSanitized := strings.ReplaceAll(orphanDomain, ".", "-")
676+ var errs []error
677+
678+ errs = append(errs, removeOrphanVolumes(ctx, svc, domainSanitized, log)...)
679+ errs = append(errs, removeOrphanNetwork(ctx, svc, domainSanitized, log))
680+ errs = append(errs, removeOrphanRoute(ctx, svc, orphanDomain, log))
681+ svc.proxySvc.InvalidateTarget(ctx, orphanDomain)
682+
683+ return errors.Join(errs...)
684+}
685+
686+func removeOrphanVolumes(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) []error {
687+ _, volPrefix, _ := svc.configSvc.GetVolumeConfig()
688+ prefix := volPrefix + "-" + domainSanitized + "-"
689+
690+ volumes, err := svc.runtime.ListVolumes(ctx)
691+ if err != nil {
692+ log.Warn().Err(err).Msg("failed to list volumes for orphan cleanup")
693+ return []error{err}
694+ }
695+
696+ var errs []error
697+ for _, v := range volumes {
698+ if !strings.HasPrefix(v.Name, prefix) {
699+ continue
700+ }
701+ if err := svc.runtime.RemoveVolume(ctx, v.Name, true); err != nil {
702+ log.Warn().Err(err).Str("volume", v.Name).Msg("failed to remove orphan volume")
703+ errs = append(errs, err)
704+ }
705+ }
706+ return errs
707+}
708+
709+func removeOrphanNetwork(ctx context.Context, svc *services, domainSanitized string, log zerowrap.Logger) error {
710+ name := svc.configSvc.GetNetworkPrefix() + "-" + domainSanitized
711+ if err := svc.runtime.RemoveNetwork(ctx, name); err != nil {
712+ log.Warn().Err(err).Str("network", name).Msg("failed to remove orphan network")
713+ return err
714+ }
715+ return nil
716+}
717+
718+func removeOrphanRoute(ctx context.Context, svc *services, orphanDomain string, log zerowrap.Logger) error {
719+ err := svc.configSvc.RemoveRoute(ctx, orphanDomain)
720+ if err == nil || errors.Is(err, domain.ErrRouteNotFound) {
721+ return nil
722+ }
723+ log.Warn().Err(err).Str("domain", orphanDomain).Msg("failed to remove orphan route")
724+ return err
615 }725 }
616 726
617 // injectTelemetryMetrics creates and injects OTel metrics into services when727 // injectTelemetryMetrics creates and injects OTel metrics into services when
M internal/boundaries/out/runtime.go +5 -0
@@ -78,6 +78,11 @@ type ContainerRuntime interface {
7878 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
7979 }
8080
81+// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
82+type ContainerLister interface {
83+ ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
84+}
85+
8186 // ExecResult holds the result of executing a command in a container.
8287 type ExecResult struct {
8388 ExitCode int
@@ -78,6 +78,11 @@ type ContainerRuntime interface {
78 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error78 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
79 }79 }
80 80
81+// ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
82+type ContainerLister interface {
83+ ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
84+}
85+
81 // ExecResult holds the result of executing a command in a container.86 // ExecResult holds the result of executing a command in a container.
82 type ExecResult struct {87 type ExecResult struct {
83 ExitCode int88 ExitCode int
M internal/domain/container.go +3 -0
@@ -2,6 +2,8 @@
22 // These types are used throughout the application and have no tags or framework dependencies.
33 package domain
44
5+import "time"
6+
57 // Container represents a running container in the system.
68 type Container struct {
79 ID string
@@ -12,6 +14,7 @@ type Container struct {
1214 ExitCode int
1315 Ports []int
1416 Labels map[string]string
17+ Created time.Time
1518 }
1619
1720 // NetworkInfo represents network configuration and state.
@@ -2,6 +2,8 @@
2 // These types are used throughout the application and have no tags or framework dependencies.2 // These types are used throughout the application and have no tags or framework dependencies.
3 package domain3 package domain
4 4
5+import "time"
6+
5 // Container represents a running container in the system.7 // Container represents a running container in the system.
6 type Container struct {8 type Container struct {
7 ID string9 ID string
@@ -12,6 +14,7 @@ type Container struct {
12 ExitCode int14 ExitCode int
13 Ports []int15 Ports []int
14 Labels map[string]string16 Labels map[string]string
17+ Created time.Time
15 }18 }
16 19
17 // NetworkInfo represents network configuration and state.20 // NetworkInfo represents network configuration and state.
A internal/usecase/auto/preview/orphans.go +80 -0
@@ -0,0 +1,80 @@
1+package preview
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+const orphanGracePeriod = 5 * time.Minute
12+
13+// IsOrphanPreview returns true if the container looks like a preview (by image
14+// tag pattern and domain separator) but is not in the tracked preview list.
15+// Containers younger than 5 minutes are skipped to avoid racing with
16+// CreatePreview.
17+func IsOrphanPreview(c *domain.Container, tracked []domain.PreviewRoute, tagPatterns []string, separator string) bool {
18+ imgLabel := c.Labels[domain.LabelImage]
19+ domLabel := c.Labels[domain.LabelDomain]
20+ if imgLabel == "" || domLabel == "" {
21+ return false
22+ }
23+
24+ // Grace period: skip very recent containers.
25+ if !c.Created.IsZero() && time.Since(c.Created) < orphanGracePeriod {
26+ return false
27+ }
28+
29+ // Condition 1: image tag matches a preview pattern.
30+ tag := tagFromImage(imgLabel)
31+ if !matchesAnyPattern(tag, tagPatterns) {
32+ return false
33+ }
34+
35+ // Condition 2: domain contains the separator.
36+ if !strings.Contains(domLabel, separator) {
37+ return false
38+ }
39+
40+ // Not an orphan if tracked.
41+ for _, p := range tracked {
42+ if p.Domain == domLabel {
43+ return false
44+ }
45+ }
46+ return true
47+}
48+
49+// IsExpiredOrphan returns true if the container has exceeded the TTL based on
50+// its creation time.
51+func IsExpiredOrphan(c *domain.Container, ttl time.Duration) bool {
52+ if c.Created.IsZero() {
53+ return false // unknown creation time — don't delete, play it safe
54+ }
55+ return time.Since(c.Created) > ttl
56+}
57+
58+// tagFromImage extracts the tag portion from an image reference.
59+// "myapp:preview-feat" -> "preview-feat"
60+// "registry.example.com/myapp:preview-feat" -> "preview-feat"
61+// "myapp" -> "" (no tag)
62+func tagFromImage(image string) string {
63+ if idx := strings.Index(image, "@"); idx != -1 {
64+ return ""
65+ }
66+ if idx := strings.LastIndex(image, ":"); idx != -1 {
67+ return image[idx+1:]
68+ }
69+ return ""
70+}
71+
72+// matchesAnyPattern checks if the tag matches any of the glob patterns.
73+func matchesAnyPattern(tag string, patterns []string) bool {
74+ for _, p := range patterns {
75+ if matched, _ := filepath.Match(p, tag); matched {
76+ return true
77+ }
78+ }
79+ return false
80+}
@@ -0,0 +1,80 @@
1+package preview
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+const orphanGracePeriod = 5 * time.Minute
12+
13+// IsOrphanPreview returns true if the container looks like a preview (by image
14+// tag pattern and domain separator) but is not in the tracked preview list.
15+// Containers younger than 5 minutes are skipped to avoid racing with
16+// CreatePreview.
17+func IsOrphanPreview(c *domain.Container, tracked []domain.PreviewRoute, tagPatterns []string, separator string) bool {
18+ imgLabel := c.Labels[domain.LabelImage]
19+ domLabel := c.Labels[domain.LabelDomain]
20+ if imgLabel == "" || domLabel == "" {
21+ return false
22+ }
23+
24+ // Grace period: skip very recent containers.
25+ if !c.Created.IsZero() && time.Since(c.Created) < orphanGracePeriod {
26+ return false
27+ }
28+
29+ // Condition 1: image tag matches a preview pattern.
30+ tag := tagFromImage(imgLabel)
31+ if !matchesAnyPattern(tag, tagPatterns) {
32+ return false
33+ }
34+
35+ // Condition 2: domain contains the separator.
36+ if !strings.Contains(domLabel, separator) {
37+ return false
38+ }
39+
40+ // Not an orphan if tracked.
41+ for _, p := range tracked {
42+ if p.Domain == domLabel {
43+ return false
44+ }
45+ }
46+ return true
47+}
48+
49+// IsExpiredOrphan returns true if the container has exceeded the TTL based on
50+// its creation time.
51+func IsExpiredOrphan(c *domain.Container, ttl time.Duration) bool {
52+ if c.Created.IsZero() {
53+ return false // unknown creation time — don't delete, play it safe
54+ }
55+ return time.Since(c.Created) > ttl
56+}
57+
58+// tagFromImage extracts the tag portion from an image reference.
59+// "myapp:preview-feat" -> "preview-feat"
60+// "registry.example.com/myapp:preview-feat" -> "preview-feat"
61+// "myapp" -> "" (no tag)
62+func tagFromImage(image string) string {
63+ if idx := strings.Index(image, "@"); idx != -1 {
64+ return ""
65+ }
66+ if idx := strings.LastIndex(image, ":"); idx != -1 {
67+ return image[idx+1:]
68+ }
69+ return ""
70+}
71+
72+// matchesAnyPattern checks if the tag matches any of the glob patterns.
73+func matchesAnyPattern(tag string, patterns []string) bool {
74+ for _, p := range patterns {
75+ if matched, _ := filepath.Match(p, tag); matched {
76+ return true
77+ }
78+ }
79+ return false
80+}
A internal/usecase/auto/preview/orphans_test.go +165 -0
@@ -0,0 +1,165 @@
1+package preview
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestIsOrphanPreview(t *testing.T) {
13+ patterns := []string{"preview-*"}
14+ separator := "--"
15+
16+ tests := []struct {
17+ name string
18+ c *domain.Container
19+ tracked []domain.PreviewRoute
20+ want bool
21+ }{
22+ {
23+ name: "orphan preview container",
24+ c: &domain.Container{
25+ Created: time.Now().Add(-1 * time.Hour),
26+ Labels: map[string]string{
27+ domain.LabelImage: "myapp:preview-feat",
28+ domain.LabelDomain: "myapp--feat.example.com",
29+ },
30+ },
31+ tracked: nil,
32+ want: true,
33+ },
34+ {
35+ name: "tracked preview is not orphan",
36+ c: &domain.Container{
37+ Created: time.Now().Add(-1 * time.Hour),
38+ Labels: map[string]string{
39+ domain.LabelImage: "myapp:preview-feat",
40+ domain.LabelDomain: "myapp--feat.example.com",
41+ },
42+ },
43+ tracked: []domain.PreviewRoute{
44+ {Domain: "myapp--feat.example.com"},
45+ },
46+ want: false,
47+ },
48+ {
49+ name: "production container — no separator in domain",
50+ c: &domain.Container{
51+ Created: time.Now().Add(-1 * time.Hour),
52+ Labels: map[string]string{
53+ domain.LabelImage: "myapp:preview-release",
54+ domain.LabelDomain: "myapp.example.com",
55+ },
56+ },
57+ tracked: nil,
58+ want: false,
59+ },
60+ {
61+ name: "production container — no preview tag",
62+ c: &domain.Container{
63+ Created: time.Now().Add(-1 * time.Hour),
64+ Labels: map[string]string{
65+ domain.LabelImage: "myapp:latest",
66+ domain.LabelDomain: "myapp--feat.example.com",
67+ },
68+ },
69+ tracked: nil,
70+ want: false,
71+ },
72+ {
73+ name: "container without gordon labels",
74+ c: &domain.Container{
75+ Labels: map[string]string{},
76+ },
77+ tracked: nil,
78+ want: false,
79+ },
80+ {
81+ name: "container too young — grace period",
82+ c: &domain.Container{
83+ Created: time.Now().Add(-2 * time.Minute),
84+ Labels: map[string]string{
85+ domain.LabelImage: "myapp:preview-feat",
86+ domain.LabelDomain: "myapp--feat.example.com",
87+ },
88+ },
89+ tracked: nil,
90+ want: false,
91+ },
92+ {
93+ name: "digest-only image — no tag to match",
94+ c: &domain.Container{
95+ Created: time.Now().Add(-1 * time.Hour),
96+ Labels: map[string]string{
97+ domain.LabelImage: "myapp@sha256:abc123",
98+ domain.LabelDomain: "myapp--feat.example.com",
99+ },
100+ },
101+ tracked: nil,
102+ want: false,
103+ },
104+ }
105+
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ got := IsOrphanPreview(tt.c, tt.tracked, patterns, separator)
109+ assert.Equal(t, tt.want, got)
110+ })
111+ }
112+}
113+
114+func TestIsExpiredOrphan(t *testing.T) {
115+ ttl := 48 * time.Hour
116+
117+ tests := []struct {
118+ name string
119+ created time.Time
120+ want bool
121+ }{
122+ {
123+ name: "expired — older than TTL",
124+ created: time.Now().Add(-72 * time.Hour),
125+ want: true,
126+ },
127+ {
128+ name: "not expired — within TTL",
129+ created: time.Now().Add(-12 * time.Hour),
130+ want: false,
131+ },
132+ {
133+ name: "zero time — unknown, skip to be safe",
134+ created: time.Time{},
135+ want: false,
136+ },
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ c := &domain.Container{Created: tt.created}
142+ got := IsExpiredOrphan(c, ttl)
143+ assert.Equal(t, tt.want, got)
144+ })
145+ }
146+}
147+
148+func TestTagFromImage(t *testing.T) {
149+ tests := []struct {
150+ image string
151+ want string
152+ }{
153+ {"myapp:preview-feat", "preview-feat"},
154+ {"registry.example.com/myapp:preview-feat", "preview-feat"},
155+ {"myapp:latest", "latest"},
156+ {"myapp", ""},
157+ {"myapp@sha256:abc123", ""},
158+ }
159+
160+ for _, tt := range tests {
161+ t.Run(tt.image, func(t *testing.T) {
162+ assert.Equal(t, tt.want, tagFromImage(tt.image))
163+ })
164+ }
165+}
@@ -0,0 +1,165 @@
1+package preview
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestIsOrphanPreview(t *testing.T) {
13+ patterns := []string{"preview-*"}
14+ separator := "--"
15+
16+ tests := []struct {
17+ name string
18+ c *domain.Container
19+ tracked []domain.PreviewRoute
20+ want bool
21+ }{
22+ {
23+ name: "orphan preview container",
24+ c: &domain.Container{
25+ Created: time.Now().Add(-1 * time.Hour),
26+ Labels: map[string]string{
27+ domain.LabelImage: "myapp:preview-feat",
28+ domain.LabelDomain: "myapp--feat.example.com",
29+ },
30+ },
31+ tracked: nil,
32+ want: true,
33+ },
34+ {
35+ name: "tracked preview is not orphan",
36+ c: &domain.Container{
37+ Created: time.Now().Add(-1 * time.Hour),
38+ Labels: map[string]string{
39+ domain.LabelImage: "myapp:preview-feat",
40+ domain.LabelDomain: "myapp--feat.example.com",
41+ },
42+ },
43+ tracked: []domain.PreviewRoute{
44+ {Domain: "myapp--feat.example.com"},
45+ },
46+ want: false,
47+ },
48+ {
49+ name: "production container — no separator in domain",
50+ c: &domain.Container{
51+ Created: time.Now().Add(-1 * time.Hour),
52+ Labels: map[string]string{
53+ domain.LabelImage: "myapp:preview-release",
54+ domain.LabelDomain: "myapp.example.com",
55+ },
56+ },
57+ tracked: nil,
58+ want: false,
59+ },
60+ {
61+ name: "production container — no preview tag",
62+ c: &domain.Container{
63+ Created: time.Now().Add(-1 * time.Hour),
64+ Labels: map[string]string{
65+ domain.LabelImage: "myapp:latest",
66+ domain.LabelDomain: "myapp--feat.example.com",
67+ },
68+ },
69+ tracked: nil,
70+ want: false,
71+ },
72+ {
73+ name: "container without gordon labels",
74+ c: &domain.Container{
75+ Labels: map[string]string{},
76+ },
77+ tracked: nil,
78+ want: false,
79+ },
80+ {
81+ name: "container too young — grace period",
82+ c: &domain.Container{
83+ Created: time.Now().Add(-2 * time.Minute),
84+ Labels: map[string]string{
85+ domain.LabelImage: "myapp:preview-feat",
86+ domain.LabelDomain: "myapp--feat.example.com",
87+ },
88+ },
89+ tracked: nil,
90+ want: false,
91+ },
92+ {
93+ name: "digest-only image — no tag to match",
94+ c: &domain.Container{
95+ Created: time.Now().Add(-1 * time.Hour),
96+ Labels: map[string]string{
97+ domain.LabelImage: "myapp@sha256:abc123",
98+ domain.LabelDomain: "myapp--feat.example.com",
99+ },
100+ },
101+ tracked: nil,
102+ want: false,
103+ },
104+ }
105+
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ got := IsOrphanPreview(tt.c, tt.tracked, patterns, separator)
109+ assert.Equal(t, tt.want, got)
110+ })
111+ }
112+}
113+
114+func TestIsExpiredOrphan(t *testing.T) {
115+ ttl := 48 * time.Hour
116+
117+ tests := []struct {
118+ name string
119+ created time.Time
120+ want bool
121+ }{
122+ {
123+ name: "expired — older than TTL",
124+ created: time.Now().Add(-72 * time.Hour),
125+ want: true,
126+ },
127+ {
128+ name: "not expired — within TTL",
129+ created: time.Now().Add(-12 * time.Hour),
130+ want: false,
131+ },
132+ {
133+ name: "zero time — unknown, skip to be safe",
134+ created: time.Time{},
135+ want: false,
136+ },
137+ }
138+
139+ for _, tt := range tests {
140+ t.Run(tt.name, func(t *testing.T) {
141+ c := &domain.Container{Created: tt.created}
142+ got := IsExpiredOrphan(c, ttl)
143+ assert.Equal(t, tt.want, got)
144+ })
145+ }
146+}
147+
148+func TestTagFromImage(t *testing.T) {
149+ tests := []struct {
150+ image string
151+ want string
152+ }{
153+ {"myapp:preview-feat", "preview-feat"},
154+ {"registry.example.com/myapp:preview-feat", "preview-feat"},
155+ {"myapp:latest", "latest"},
156+ {"myapp", ""},
157+ {"myapp@sha256:abc123", ""},
158+ }
159+
160+ for _, tt := range tests {
161+ t.Run(tt.image, func(t *testing.T) {
162+ assert.Equal(t, tt.want, tagFromImage(tt.image))
163+ })
164+ }
165+}
M internal/usecase/auto/preview/service.go +33 -3
@@ -225,9 +225,36 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
225225 return expired
226226 }
227227
228-// StartTicker starts a background goroutine that checks for expired previews.
229-// The teardownFn is called for each expired preview to clean up containers/volumes.
230-func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) {
228+// CollectOrphans lists Docker containers and returns those that look like
229+// expired preview orphans (not tracked, matching tag pattern + separator,
230+// past TTL based on container creation time).
231+func (s *Service) CollectOrphans(ctx context.Context, lister out.ContainerLister, tagPatterns []string, separator string) []*domain.Container {
232+ containers, err := lister.ListContainers(ctx, true)
233+ if err != nil {
234+ log := zerowrap.FromCtx(ctx)
235+ log.Warn().Err(err).Msg("orphan GC: failed to list containers")
236+ return nil
237+ }
238+
239+ s.mu.RLock()
240+ tracked := make([]domain.PreviewRoute, len(s.previews))
241+ copy(tracked, s.previews)
242+ s.mu.RUnlock()
243+
244+ var orphans []*domain.Container
245+ for _, c := range containers {
246+ if IsOrphanPreview(c, tracked, tagPatterns, separator) && IsExpiredOrphan(c, s.defaultTTL) {
247+ orphans = append(orphans, c)
248+ }
249+ }
250+ return orphans
251+}
252+
253+// StartTicker starts a background goroutine that checks for expired previews
254+// and orphaned preview containers. The teardownFn is called for each expired
255+// tracked preview. The orphanGCFn, if non-nil, is called on each tick to
256+// clean up orphaned containers.
257+func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute), orphanGCFn func(context.Context)) {
231258 go func() {
232259 ticker := time.NewTicker(interval)
233260 defer ticker.Stop()
@@ -240,6 +267,9 @@ func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teard
240267 for _, p := range expired {
241268 teardownFn(ctx, p)
242269 }
270+ if orphanGCFn != nil {
271+ orphanGCFn(ctx)
272+ }
243273 }
244274 }
245275 }()
@@ -225,9 +225,36 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
225 return expired225 return expired
226 }226 }
227 227
228-// StartTicker starts a background goroutine that checks for expired previews.228+// CollectOrphans lists Docker containers and returns those that look like
229-// The teardownFn is called for each expired preview to clean up containers/volumes.229+// expired preview orphans (not tracked, matching tag pattern + separator,
230-func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute)) {230+// past TTL based on container creation time).
231+func (s *Service) CollectOrphans(ctx context.Context, lister out.ContainerLister, tagPatterns []string, separator string) []*domain.Container {
232+ containers, err := lister.ListContainers(ctx, true)
233+ if err != nil {
234+ log := zerowrap.FromCtx(ctx)
235+ log.Warn().Err(err).Msg("orphan GC: failed to list containers")
236+ return nil
237+ }
238+
239+ s.mu.RLock()
240+ tracked := make([]domain.PreviewRoute, len(s.previews))
241+ copy(tracked, s.previews)
242+ s.mu.RUnlock()
243+
244+ var orphans []*domain.Container
245+ for _, c := range containers {
246+ if IsOrphanPreview(c, tracked, tagPatterns, separator) && IsExpiredOrphan(c, s.defaultTTL) {
247+ orphans = append(orphans, c)
248+ }
249+ }
250+ return orphans
251+}
252+
253+// StartTicker starts a background goroutine that checks for expired previews
254+// and orphaned preview containers. The teardownFn is called for each expired
255+// tracked preview. The orphanGCFn, if non-nil, is called on each tick to
256+// clean up orphaned containers.
257+func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teardownFn func(context.Context, domain.PreviewRoute), orphanGCFn func(context.Context)) {
231 go func() {258 go func() {
232 ticker := time.NewTicker(interval)259 ticker := time.NewTicker(interval)
233 defer ticker.Stop()260 defer ticker.Stop()
@@ -240,6 +267,9 @@ func (s *Service) StartTicker(ctx context.Context, interval time.Duration, teard
240 for _, p := range expired {267 for _, p := range expired {
241 teardownFn(ctx, p)268 teardownFn(ctx, p)
242 }269 }
270+ if orphanGCFn != nil {
271+ orphanGCFn(ctx)
272+ }
243 }273 }
244 }274 }
245 }()275 }()
M internal/usecase/auto/preview/service_test.go +73 -0
@@ -140,3 +140,76 @@ func TestPreviewService_Update_NotFound(t *testing.T) {
140140 err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"})
141141 assert.ErrorIs(t, err, domain.ErrPreviewNotFound)
142142 }
143+
144+type fakeRuntime struct {
145+ containers []*domain.Container
146+}
147+
148+func (f *fakeRuntime) ListContainers(_ context.Context, _ bool) ([]*domain.Container, error) {
149+ return f.containers, nil
150+}
151+
152+func TestPreviewService_CollectOrphans(t *testing.T) {
153+ store := &fakeStore{
154+ previews: []domain.PreviewRoute{
155+ {Name: "tracked", Domain: "myapp--tracked.example.com"},
156+ },
157+ }
158+ svc := NewService(store, 48*time.Hour)
159+ require.NoError(t, svc.Load(t.Context()))
160+
161+ runtime := &fakeRuntime{
162+ containers: []*domain.Container{
163+ {
164+ Name: "gordon-myapp--orphan.example.com",
165+ Created: time.Now().Add(-72 * time.Hour),
166+ Labels: map[string]string{
167+ domain.LabelImage: "myapp:preview-orphan",
168+ domain.LabelDomain: "myapp--orphan.example.com",
169+ },
170+ },
171+ {
172+ Name: "gordon-myapp--tracked.example.com",
173+ Created: time.Now().Add(-72 * time.Hour),
174+ Labels: map[string]string{
175+ domain.LabelImage: "myapp:preview-tracked",
176+ domain.LabelDomain: "myapp--tracked.example.com",
177+ },
178+ },
179+ {
180+ Name: "gordon-prod.example.com",
181+ Created: time.Now().Add(-72 * time.Hour),
182+ Labels: map[string]string{
183+ domain.LabelImage: "myapp:latest",
184+ domain.LabelDomain: "prod.example.com",
185+ },
186+ },
187+ },
188+ }
189+
190+ orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--")
191+ assert.Len(t, orphans, 1)
192+ assert.Equal(t, "gordon-myapp--orphan.example.com", orphans[0].Name)
193+}
194+
195+func TestPreviewService_CollectOrphans_NotExpired(t *testing.T) {
196+ store := &fakeStore{}
197+ svc := NewService(store, 48*time.Hour)
198+ require.NoError(t, svc.Load(t.Context()))
199+
200+ runtime := &fakeRuntime{
201+ containers: []*domain.Container{
202+ {
203+ Name: "gordon-myapp--recent.example.com",
204+ Created: time.Now().Add(-12 * time.Hour),
205+ Labels: map[string]string{
206+ domain.LabelImage: "myapp:preview-recent",
207+ domain.LabelDomain: "myapp--recent.example.com",
208+ },
209+ },
210+ },
211+ }
212+
213+ orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--")
214+ assert.Empty(t, orphans)
215+}
@@ -140,3 +140,76 @@ func TestPreviewService_Update_NotFound(t *testing.T) {
140 err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"})140 err := svc.Update(t.Context(), domain.PreviewRoute{Name: "nonexistent"})
141 assert.ErrorIs(t, err, domain.ErrPreviewNotFound)141 assert.ErrorIs(t, err, domain.ErrPreviewNotFound)
142 }142 }
143+
144+type fakeRuntime struct {
145+ containers []*domain.Container
146+}
147+
148+func (f *fakeRuntime) ListContainers(_ context.Context, _ bool) ([]*domain.Container, error) {
149+ return f.containers, nil
150+}
151+
152+func TestPreviewService_CollectOrphans(t *testing.T) {
153+ store := &fakeStore{
154+ previews: []domain.PreviewRoute{
155+ {Name: "tracked", Domain: "myapp--tracked.example.com"},
156+ },
157+ }
158+ svc := NewService(store, 48*time.Hour)
159+ require.NoError(t, svc.Load(t.Context()))
160+
161+ runtime := &fakeRuntime{
162+ containers: []*domain.Container{
163+ {
164+ Name: "gordon-myapp--orphan.example.com",
165+ Created: time.Now().Add(-72 * time.Hour),
166+ Labels: map[string]string{
167+ domain.LabelImage: "myapp:preview-orphan",
168+ domain.LabelDomain: "myapp--orphan.example.com",
169+ },
170+ },
171+ {
172+ Name: "gordon-myapp--tracked.example.com",
173+ Created: time.Now().Add(-72 * time.Hour),
174+ Labels: map[string]string{
175+ domain.LabelImage: "myapp:preview-tracked",
176+ domain.LabelDomain: "myapp--tracked.example.com",
177+ },
178+ },
179+ {
180+ Name: "gordon-prod.example.com",
181+ Created: time.Now().Add(-72 * time.Hour),
182+ Labels: map[string]string{
183+ domain.LabelImage: "myapp:latest",
184+ domain.LabelDomain: "prod.example.com",
185+ },
186+ },
187+ },
188+ }
189+
190+ orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--")
191+ assert.Len(t, orphans, 1)
192+ assert.Equal(t, "gordon-myapp--orphan.example.com", orphans[0].Name)
193+}
194+
195+func TestPreviewService_CollectOrphans_NotExpired(t *testing.T) {
196+ store := &fakeStore{}
197+ svc := NewService(store, 48*time.Hour)
198+ require.NoError(t, svc.Load(t.Context()))
199+
200+ runtime := &fakeRuntime{
201+ containers: []*domain.Container{
202+ {
203+ Name: "gordon-myapp--recent.example.com",
204+ Created: time.Now().Add(-12 * time.Hour),
205+ Labels: map[string]string{
206+ domain.LabelImage: "myapp:preview-recent",
207+ domain.LabelDomain: "myapp--recent.example.com",
208+ },
209+ },
210+ },
211+ }
212+
213+ orphans := svc.CollectOrphans(t.Context(), runtime, []string{"preview-*"}, "--")
214+ assert.Empty(t, orphans)
215+}

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