detached 686191e

fix(preview): DNS-safe naming, testable IsExpired, and CLI output helpers

- Replace fmt.Printf/Println with cmd.OutOrStdout() in all preview CLI subcommands
- Handle --json flag in preview list command
- Wrap detectGitBranch error with context
- Add DNS-safe character sanitization to SanitizeBranchName (underscores, dots, specials, 63-char limit)
- Handle filepath.Match errors in ExtractPreviewName by skipping malformed patterns
- Make IsExpired accept a time.Time parameter for testability; update all call sites
- Add test cases for new sanitization rules
commit
686191eb0769616b089463ba3f3378d57d9f47ad
author
brice · · 2026-03-19 05:27 UTC
parent
b84b07f
signature
unsigned
6 files · +71 -16
M internal/adapters/in/cli/preview.go +10 -5
@@ -54,7 +54,7 @@ func newPreviewCreateCmd() *cobra.Command {
5454 name = preview.SanitizeBranchName(branch)
5555 }
5656
57- fmt.Printf("Creating preview: %s\n", name)
57+ fmt.Fprintf(cmd.OutOrStdout(), "Creating preview: %s\n", name)
5858 // TODO: Reuse push mechanics (build, tag with preview-{name}, push)
5959 // TODO: Wire to remote API or local service
6060 _ = ttl
@@ -77,7 +77,12 @@ func newPreviewListCmd() *cobra.Command {
7777 Short: "List active preview environments",
7878 RunE: func(cmd *cobra.Command, args []string) error {
7979 // TODO: Wire to local service or admin API
80- fmt.Println("No active previews")
80+ if jsonOutput {
81+ // Output empty JSON array for now (will be wired later)
82+ fmt.Fprintln(cmd.OutOrStdout(), "[]")
83+ } else {
84+ fmt.Fprintln(cmd.OutOrStdout(), "No active previews")
85+ }
8186 return nil
8287 },
8388 }
@@ -92,7 +97,7 @@ func newPreviewDeleteCmd() *cobra.Command {
9297 Args: cobra.ExactArgs(1),
9398 RunE: func(cmd *cobra.Command, args []string) error {
9499 // TODO: Wire to local service or admin API
95- fmt.Printf("Deleting preview: %s\n", args[0])
100+ fmt.Fprintf(cmd.OutOrStdout(), "Deleting preview: %s\n", args[0])
96101 return nil
97102 },
98103 }
@@ -106,7 +111,7 @@ func newPreviewExtendCmd() *cobra.Command {
106111 Args: cobra.ExactArgs(1),
107112 RunE: func(cmd *cobra.Command, args []string) error {
108113 // TODO: Wire to local service or admin API
109- fmt.Printf("Extending preview %s by %s\n", args[0], ttl)
114+ fmt.Fprintf(cmd.OutOrStdout(), "Extending preview %s by %s\n", args[0], ttl)
110115 return nil
111116 },
112117 }
@@ -117,7 +122,7 @@ func newPreviewExtendCmd() *cobra.Command {
117122 func detectGitBranch() (string, error) {
118123 out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
119124 if err != nil {
120- return "", err
125+ return "", fmt.Errorf("detect git branch: %w", err)
121126 }
122127 return strings.TrimSpace(string(out)), nil
123128 }
@@ -54,7 +54,7 @@ func newPreviewCreateCmd() *cobra.Command {
54 name = preview.SanitizeBranchName(branch)54 name = preview.SanitizeBranchName(branch)
55 }55 }
56 56
57- fmt.Printf("Creating preview: %s\n", name)57+ fmt.Fprintf(cmd.OutOrStdout(), "Creating preview: %s\n", name)
58 // TODO: Reuse push mechanics (build, tag with preview-{name}, push)58 // TODO: Reuse push mechanics (build, tag with preview-{name}, push)
59 // TODO: Wire to remote API or local service59 // TODO: Wire to remote API or local service
60 _ = ttl60 _ = ttl
@@ -77,7 +77,12 @@ func newPreviewListCmd() *cobra.Command {
77 Short: "List active preview environments",77 Short: "List active preview environments",
78 RunE: func(cmd *cobra.Command, args []string) error {78 RunE: func(cmd *cobra.Command, args []string) error {
79 // TODO: Wire to local service or admin API79 // TODO: Wire to local service or admin API
80- fmt.Println("No active previews")80+ if jsonOutput {
81+ // Output empty JSON array for now (will be wired later)
82+ fmt.Fprintln(cmd.OutOrStdout(), "[]")
83+ } else {
84+ fmt.Fprintln(cmd.OutOrStdout(), "No active previews")
85+ }
81 return nil86 return nil
82 },87 },
83 }88 }
@@ -92,7 +97,7 @@ func newPreviewDeleteCmd() *cobra.Command {
92 Args: cobra.ExactArgs(1),97 Args: cobra.ExactArgs(1),
93 RunE: func(cmd *cobra.Command, args []string) error {98 RunE: func(cmd *cobra.Command, args []string) error {
94 // TODO: Wire to local service or admin API99 // TODO: Wire to local service or admin API
95- fmt.Printf("Deleting preview: %s\n", args[0])100+ fmt.Fprintf(cmd.OutOrStdout(), "Deleting preview: %s\n", args[0])
96 return nil101 return nil
97 },102 },
98 }103 }
@@ -106,7 +111,7 @@ func newPreviewExtendCmd() *cobra.Command {
106 Args: cobra.ExactArgs(1),111 Args: cobra.ExactArgs(1),
107 RunE: func(cmd *cobra.Command, args []string) error {112 RunE: func(cmd *cobra.Command, args []string) error {
108 // TODO: Wire to local service or admin API113 // TODO: Wire to local service or admin API
109- fmt.Printf("Extending preview %s by %s\n", args[0], ttl)114+ fmt.Fprintf(cmd.OutOrStdout(), "Extending preview %s by %s\n", args[0], ttl)
110 return nil115 return nil
111 },116 },
112 }117 }
@@ -117,7 +122,7 @@ func newPreviewExtendCmd() *cobra.Command {
117 func detectGitBranch() (string, error) {122 func detectGitBranch() (string, error) {
118 out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204123 out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204
119 if err != nil {124 if err != nil {
120- return "", err125+ return "", fmt.Errorf("detect git branch: %w", err)
121 }126 }
122 return strings.TrimSpace(string(out)), nil127 return strings.TrimSpace(string(out)), nil
123 }128 }
M internal/domain/preview.go +2 -2
@@ -16,6 +16,6 @@ type PreviewRoute struct {
1616 }
1717
1818 // IsExpired returns true if the preview has exceeded its TTL.
19-func (p PreviewRoute) IsExpired() bool {
20- return time.Now().After(p.ExpiresAt)
19+func (p PreviewRoute) IsExpired(now time.Time) bool {
20+ return now.After(p.ExpiresAt)
2121 }
@@ -16,6 +16,6 @@ type PreviewRoute struct {
16 }16 }
17 17
18 // IsExpired returns true if the preview has exceeded its TTL.18 // IsExpired returns true if the preview has exceeded its TTL.
19-func (p PreviewRoute) IsExpired() bool {19+func (p PreviewRoute) IsExpired(now time.Time) bool {
20- return time.Now().After(p.ExpiresAt)20+ return now.After(p.ExpiresAt)
21 }21 }
M internal/domain/preview_test.go +1 -1
@@ -20,7 +20,7 @@ func TestPreviewRoute_IsExpired(t *testing.T) {
2020 for _, tt := range tests {
2121 t.Run(tt.name, func(t *testing.T) {
2222 p := PreviewRoute{ExpiresAt: tt.expiresAt}
23- assert.Equal(t, tt.want, p.IsExpired())
23+ assert.Equal(t, tt.want, p.IsExpired(time.Now()))
2424 })
2525 }
2626 }
@@ -20,7 +20,7 @@ func TestPreviewRoute_IsExpired(t *testing.T) {
20 for _, tt := range tests {20 for _, tt := range tests {
21 t.Run(tt.name, func(t *testing.T) {21 t.Run(tt.name, func(t *testing.T) {
22 p := PreviewRoute{ExpiresAt: tt.expiresAt}22 p := PreviewRoute{ExpiresAt: tt.expiresAt}
23- assert.Equal(t, tt.want, p.IsExpired())23+ assert.Equal(t, tt.want, p.IsExpired(time.Now()))
24 })24 })
25 }25 }
26 }26 }
M internal/usecase/auto/preview/naming.go +30 -2
@@ -10,7 +10,11 @@ import (
1010 // "preview-login-redesign" with pattern "preview-*" → "login-redesign"
1111 func ExtractPreviewName(tag string, patterns []string) string {
1212 for _, pattern := range patterns {
13- if matched, _ := filepath.Match(pattern, tag); matched {
13+ matched, err := filepath.Match(pattern, tag)
14+ if err != nil {
15+ continue // skip malformed patterns
16+ }
17+ if matched {
1418 prefix := strings.TrimSuffix(pattern, "*")
1519 return strings.TrimPrefix(tag, prefix)
1620 }
@@ -30,7 +34,7 @@ func GeneratePreviewDomain(baseRoute, previewName, separator string) (string, er
3034 return app + separator + previewName + rest, nil
3135 }
3236
33-// SanitizeBranchName converts a git branch name to a valid preview name.
37+// SanitizeBranchName converts a git branch name to a valid DNS-safe preview name.
3438 // "feat/login-redesign" → "login-redesign"
3539 func SanitizeBranchName(branch string) string {
3640 branch = strings.ToLower(branch)
@@ -40,6 +44,30 @@ func SanitizeBranchName(branch string) string {
4044 break
4145 }
4246 }
47+ // Replace disallowed characters with dashes
4348 branch = strings.ReplaceAll(branch, "/", "-")
49+ branch = strings.ReplaceAll(branch, "_", "-")
50+ branch = strings.ReplaceAll(branch, ".", "-")
51+
52+ // Remove any remaining non DNS-safe characters
53+ var result strings.Builder
54+ for _, c := range branch {
55+ if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
56+ result.WriteRune(c)
57+ }
58+ }
59+ branch = result.String()
60+
61+ // Collapse consecutive dashes and trim
62+ for strings.Contains(branch, "--") {
63+ branch = strings.ReplaceAll(branch, "--", "-")
64+ }
65+ branch = strings.Trim(branch, "-")
66+
67+ // Truncate to 63 chars (DNS label limit)
68+ if len(branch) > 63 {
69+ branch = branch[:63]
70+ branch = strings.TrimRight(branch, "-")
71+ }
4472 return branch
4573 }
@@ -10,7 +10,11 @@ import (
10 // "preview-login-redesign" with pattern "preview-*" → "login-redesign"10 // "preview-login-redesign" with pattern "preview-*" → "login-redesign"
11 func ExtractPreviewName(tag string, patterns []string) string {11 func ExtractPreviewName(tag string, patterns []string) string {
12 for _, pattern := range patterns {12 for _, pattern := range patterns {
13- if matched, _ := filepath.Match(pattern, tag); matched {13+ matched, err := filepath.Match(pattern, tag)
14+ if err != nil {
15+ continue // skip malformed patterns
16+ }
17+ if matched {
14 prefix := strings.TrimSuffix(pattern, "*")18 prefix := strings.TrimSuffix(pattern, "*")
15 return strings.TrimPrefix(tag, prefix)19 return strings.TrimPrefix(tag, prefix)
16 }20 }
@@ -30,7 +34,7 @@ func GeneratePreviewDomain(baseRoute, previewName, separator string) (string, er
30 return app + separator + previewName + rest, nil34 return app + separator + previewName + rest, nil
31 }35 }
32 36
33-// SanitizeBranchName converts a git branch name to a valid preview name.37+// SanitizeBranchName converts a git branch name to a valid DNS-safe preview name.
34 // "feat/login-redesign" → "login-redesign"38 // "feat/login-redesign" → "login-redesign"
35 func SanitizeBranchName(branch string) string {39 func SanitizeBranchName(branch string) string {
36 branch = strings.ToLower(branch)40 branch = strings.ToLower(branch)
@@ -40,6 +44,30 @@ func SanitizeBranchName(branch string) string {
40 break44 break
41 }45 }
42 }46 }
47+ // Replace disallowed characters with dashes
43 branch = strings.ReplaceAll(branch, "/", "-")48 branch = strings.ReplaceAll(branch, "/", "-")
49+ branch = strings.ReplaceAll(branch, "_", "-")
50+ branch = strings.ReplaceAll(branch, ".", "-")
51+
52+ // Remove any remaining non DNS-safe characters
53+ var result strings.Builder
54+ for _, c := range branch {
55+ if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' {
56+ result.WriteRune(c)
57+ }
58+ }
59+ branch = result.String()
60+
61+ // Collapse consecutive dashes and trim
62+ for strings.Contains(branch, "--") {
63+ branch = strings.ReplaceAll(branch, "--", "-")
64+ }
65+ branch = strings.Trim(branch, "-")
66+
67+ // Truncate to 63 chars (DNS label limit)
68+ if len(branch) > 63 {
69+ branch = branch[:63]
70+ branch = strings.TrimRight(branch, "-")
71+ }
44 return branch72 return branch
45 }73 }
M internal/usecase/auto/preview/naming_test.go +5 -0
@@ -1,6 +1,7 @@
11 package preview
22
33 import (
4+ "strings"
45 "testing"
56
67 "github.com/stretchr/testify/assert"
@@ -64,6 +65,10 @@ func TestSanitizeBranchName(t *testing.T) {
6465 {"simple", "main", "main"},
6566 {"nested", "feat/ui/button", "ui-button"},
6667 {"uppercase", "Feat/MyBranch", "mybranch"},
68+ {"dots and underscores", "feat/my_branch.name", "my-branch-name"},
69+ {"special chars", "feat/hello@world!", "helloworld"},
70+ {"long branch", strings.Repeat("a", 70), strings.Repeat("a", 63)},
71+ {"consecutive dashes", "feat/a--b--c", "a-b-c"},
6772 }
6873 for _, tt := range tests {
6974 t.Run(tt.name, func(t *testing.T) {
@@ -1,6 +1,7 @@
1 package preview1 package preview
2 2
3 import (3 import (
4+ "strings"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
@@ -64,6 +65,10 @@ func TestSanitizeBranchName(t *testing.T) {
64 {"simple", "main", "main"},65 {"simple", "main", "main"},
65 {"nested", "feat/ui/button", "ui-button"},66 {"nested", "feat/ui/button", "ui-button"},
66 {"uppercase", "Feat/MyBranch", "mybranch"},67 {"uppercase", "Feat/MyBranch", "mybranch"},
68+ {"dots and underscores", "feat/my_branch.name", "my-branch-name"},
69+ {"special chars", "feat/hello@world!", "helloworld"},
70+ {"long branch", strings.Repeat("a", 70), strings.Repeat("a", 63)},
71+ {"consecutive dashes", "feat/a--b--c", "a-b-c"},
67 }72 }
68 for _, tt := range tests {73 for _, tt := range tests {
69 t.Run(tt.name, func(t *testing.T) {74 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/auto/preview/service.go +23 -6
@@ -6,6 +6,8 @@ import (
66 "sync"
77 "time"
88
9+ "github.com/bnema/zerowrap"
10+
911 "github.com/bnema/gordon/internal/boundaries/out"
1012 "github.com/bnema/gordon/internal/domain"
1113 )
@@ -103,7 +105,7 @@ func (s *Service) GetExpired() []domain.PreviewRoute {
103105 defer s.mu.RUnlock()
104106 var expired []domain.PreviewRoute
105107 for _, p := range s.previews {
106- if p.IsExpired() {
108+ if p.IsExpired(time.Now()) {
107109 expired = append(expired, p)
108110 }
109111 }
@@ -117,7 +119,7 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
117119 var expired []domain.PreviewRoute
118120 var active []domain.PreviewRoute
119121 for _, p := range s.previews {
120- if p.IsExpired() {
122+ if p.IsExpired(time.Now()) {
121123 expired = append(expired, p)
122124 } else {
123125 active = append(active, p)
@@ -129,7 +131,11 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
129131 s.mu.Unlock()
130132
131133 if len(expired) > 0 {
132- _ = s.store.Save(ctx, cp)
134+ if err := s.store.Save(ctx, cp); err != nil {
135+ // Log but don't fail — expired previews are already removed from memory
136+ log := zerowrap.FromCtx(ctx)
137+ log.Warn().Err(err).Msg("failed to persist preview state after cleanup")
138+ }
133139 }
134140 return expired
135141 }
@@ -165,6 +171,13 @@ func (s *Service) AcquireNameLock(name string) *sync.Mutex {
165171 return mu
166172 }
167173
174+// ReleaseNameLock removes the per-name mutex from the map after use.
175+func (s *Service) ReleaseNameLock(name string) {
176+ s.nameLockMu.Lock()
177+ defer s.nameLockMu.Unlock()
178+ delete(s.nameLocks, name)
179+}
180+
168181 // CreatePreviewRequest contains parameters for creating a preview.
169182 type CreatePreviewRequest struct {
170183 Name string
@@ -176,10 +189,13 @@ type CreatePreviewRequest struct {
176189 }
177190
178191 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
179-func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) {
192+func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
180193 lock := s.AcquireNameLock(req.Name)
181194 lock.Lock()
182- defer lock.Unlock()
195+ defer func() {
196+ lock.Unlock()
197+ s.ReleaseNameLock(req.Name)
198+ }()
183199
184200 now := time.Now()
185201 preview := domain.PreviewRoute{
@@ -194,6 +210,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) {
194210
195211 // TODO: Volume cloning and container startup will be wired in Task 13
196212 if err := s.Add(ctx, preview); err != nil {
197- return
213+ return fmt.Errorf("register preview %q: %w", req.Name, err)
198214 }
215+ return nil
199216 }
@@ -6,6 +6,8 @@ import (
6 "sync"6 "sync"
7 "time"7 "time"
8 8
9+ "github.com/bnema/zerowrap"
10+
9 "github.com/bnema/gordon/internal/boundaries/out"11 "github.com/bnema/gordon/internal/boundaries/out"
10 "github.com/bnema/gordon/internal/domain"12 "github.com/bnema/gordon/internal/domain"
11 )13 )
@@ -103,7 +105,7 @@ func (s *Service) GetExpired() []domain.PreviewRoute {
103 defer s.mu.RUnlock()105 defer s.mu.RUnlock()
104 var expired []domain.PreviewRoute106 var expired []domain.PreviewRoute
105 for _, p := range s.previews {107 for _, p := range s.previews {
106- if p.IsExpired() {108+ if p.IsExpired(time.Now()) {
107 expired = append(expired, p)109 expired = append(expired, p)
108 }110 }
109 }111 }
@@ -117,7 +119,7 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
117 var expired []domain.PreviewRoute119 var expired []domain.PreviewRoute
118 var active []domain.PreviewRoute120 var active []domain.PreviewRoute
119 for _, p := range s.previews {121 for _, p := range s.previews {
120- if p.IsExpired() {122+ if p.IsExpired(time.Now()) {
121 expired = append(expired, p)123 expired = append(expired, p)
122 } else {124 } else {
123 active = append(active, p)125 active = append(active, p)
@@ -129,7 +131,11 @@ func (s *Service) CleanupExpired(ctx context.Context) []domain.PreviewRoute {
129 s.mu.Unlock()131 s.mu.Unlock()
130 132
131 if len(expired) > 0 {133 if len(expired) > 0 {
132- _ = s.store.Save(ctx, cp)134+ if err := s.store.Save(ctx, cp); err != nil {
135+ // Log but don't fail — expired previews are already removed from memory
136+ log := zerowrap.FromCtx(ctx)
137+ log.Warn().Err(err).Msg("failed to persist preview state after cleanup")
138+ }
133 }139 }
134 return expired140 return expired
135 }141 }
@@ -165,6 +171,13 @@ func (s *Service) AcquireNameLock(name string) *sync.Mutex {
165 return mu171 return mu
166 }172 }
167 173
174+// ReleaseNameLock removes the per-name mutex from the map after use.
175+func (s *Service) ReleaseNameLock(name string) {
176+ s.nameLockMu.Lock()
177+ defer s.nameLockMu.Unlock()
178+ delete(s.nameLocks, name)
179+}
180+
168 // CreatePreviewRequest contains parameters for creating a preview.181 // CreatePreviewRequest contains parameters for creating a preview.
169 type CreatePreviewRequest struct {182 type CreatePreviewRequest struct {
170 Name string183 Name string
@@ -176,10 +189,13 @@ type CreatePreviewRequest struct {
176 }189 }
177 190
178 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.191 // CreatePreview orchestrates the full preview creation: lock, clone volumes, start containers, register.
179-func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) {192+func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) error {
180 lock := s.AcquireNameLock(req.Name)193 lock := s.AcquireNameLock(req.Name)
181 lock.Lock()194 lock.Lock()
182- defer lock.Unlock()195+ defer func() {
196+ lock.Unlock()
197+ s.ReleaseNameLock(req.Name)
198+ }()
183 199
184 now := time.Now()200 now := time.Now()
185 preview := domain.PreviewRoute{201 preview := domain.PreviewRoute{
@@ -194,6 +210,7 @@ func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) {
194 210
195 // TODO: Volume cloning and container startup will be wired in Task 13211 // TODO: Volume cloning and container startup will be wired in Task 13
196 if err := s.Add(ctx, preview); err != nil {212 if err := s.Add(ctx, preview); err != nil {
197- return213+ return fmt.Errorf("register preview %q: %w", req.Name, err)
198 }214 }
215+ return nil
199 }216 }

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 8

Tags 106

↑↓ moveenter openesc close