v3.4.1 de8f68e

push_test.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"io"
6	"os"
7	"os/exec"
8	"path/filepath"
9	"strings"
10	"testing"
11
12	"github.com/stretchr/testify/assert"
13)
14
15func TestValidateBuildArg(t *testing.T) {
16	tests := []struct {
17		name    string
18		arg     string
19		wantErr bool
20	}{
21		{"valid simple", "FOO=bar", false},
22		{"valid with underscore key", "_FOO=bar", false},
23		{"valid with numbers in key", "FOO123=bar", false},
24		{"valid empty value", "FOO=", false},
25		{"valid complex value", "FOO=bar baz=qux", false},
26		{"invalid no equals", "FOO", true},
27		{"invalid starts with number", "1FOO=bar", true},
28		{"invalid starts with dash", "-FOO=bar", true},
29		{"invalid special chars in key", "FOO-BAR=baz", true},
30		{"invalid empty string", "", true},
31		{"invalid equals only", "=value", true},
32	}
33
34	for _, tt := range tests {
35		t.Run(tt.name, func(t *testing.T) {
36			err := validateBuildArg(tt.arg)
37			if tt.wantErr {
38				assert.Error(t, err)
39			} else {
40				assert.NoError(t, err)
41			}
42		})
43	}
44}
45
46func TestParseImageRef(t *testing.T) {
47	tests := []struct {
48		name         string
49		image        string
50		wantRegistry string
51		wantName     string
52		wantTag      string
53	}{
54		{"full ref", "reg.example.com/myapp:v1.0.0", "reg.example.com", "myapp", "v1.0.0"},
55		{"no tag", "reg.example.com/myapp", "reg.example.com", "myapp", "latest"},
56		{"latest tag", "reg.example.com/myapp:latest", "reg.example.com", "myapp", "latest"},
57		{"no slash", "myapp:v1.0.0", "", "", ""},
58		{"namespace without registry host", "team/myapp:v1", "", "", ""},
59		{"localhost", "localhost/myapp:v1", "localhost", "myapp", "v1"},
60		{"registry with port", "localhost:5000/myapp:v1", "localhost:5000", "myapp", "v1"},
61		{"registry with namespace", "reg.example.com/team/myapp", "reg.example.com", "team/myapp", "latest"},
62	}
63
64	for _, tt := range tests {
65		t.Run(tt.name, func(t *testing.T) {
66			registry, name, tag := parseImageRef(tt.image)
67			assert.Equal(t, tt.wantRegistry, registry)
68			assert.Equal(t, tt.wantName, name)
69			assert.Equal(t, tt.wantTag, tag)
70		})
71	}
72}
73
74func TestClassifyPushArgument(t *testing.T) {
75	tests := []struct {
76		name string
77		arg  string
78		want classifiedPushArg
79	}{
80		{name: "tagged image latest", arg: "myapp:latest", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:latest", repository: "myapp"}},
81		{name: "tagged image semver", arg: "myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp:v1.2.3", repository: "myapp"}},
82		{name: "registry qualified image", arg: "registry.example.com/myapp:v1.2.3", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp:v1.2.3", repository: "myapp"}},
83		{name: "registry qualified digest", arg: "registry.example.com/myapp@sha256:deadbeef", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "registry.example.com/myapp@sha256:deadbeef", repository: "myapp"}},
84		{name: "legacy domain", arg: "app.example.com", want: classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: "app.example.com"}},
85		{name: "bare image name", arg: "myapp", want: classifiedPushArg{kind: pushArgKindImage, sourceRef: "myapp", repository: "myapp"}},
86	}
87
88	for _, tt := range tests {
89		t.Run(tt.name, func(t *testing.T) {
90			got := classifyPushArgument(tt.arg)
91			assert.Equal(t, tt.want, got)
92		})
93	}
94}
95
96func TestBuildAndPush_BuildArgs(t *testing.T) {
97	// Verify buildImageArgs produces --load instead of --push
98	args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
99
100	assert.Contains(t, args, "--load")
101	assert.NotContains(t, args, "--push")
102	assert.Contains(t, args, "--platform")
103	assert.Contains(t, args, "-f")
104	assert.Contains(t, args, "Dockerfile")
105	assert.Contains(t, args, "VERSION=v1.0.0")
106}
107
108func TestBuildImageArgsInjectsGitBuildArgs(t *testing.T) {
109	args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", nil, "registry/img:v1.2.3", "registry/img:latest")
110
111	// Must contain explicit KEY=VALUE for all standard git build args
112	argStr := strings.Join(args, " ")
113	for _, key := range []string{"VERSION=v1.2.3", "GIT_TAG=v1.2.3", "GIT_SHA=", "BUILD_TIME="} {
114		if !strings.Contains(argStr, key) {
115			t.Errorf("expected args to contain %q, got: %s", key, argStr)
116		}
117	}
118
119	// Must NOT contain bare "--build-arg VERSION" (without =value)
120	for i, a := range args {
121		if a == "--build-arg" && i+1 < len(args) && args[i+1] == "VERSION" {
122			t.Error("found bare '--build-arg VERSION' (without =value); should be '--build-arg VERSION=v1.2.3'")
123		}
124	}
125}
126
127func TestBuildImageArgsUserArgsOverrideDefaults(t *testing.T) {
128	userArgs := []string{"GIT_TAG=custom-override"}
129	args := buildImageArgs(context.Background(), "v1.2.3", "linux/amd64", "Dockerfile", userArgs, "r/i:v1.2.3", "r/i:latest")
130
131	// Count how many times GIT_TAG appears and track the last occurrence index.
132	// Docker uses the last occurrence of a duplicate --build-arg key, so the user
133	// override must come after the default injected value.
134	count := 0
135	lastIdx := -1
136	for i, a := range args {
137		if a == "--build-arg" && i+1 < len(args) && strings.HasPrefix(args[i+1], "GIT_TAG=") {
138			count++
139			lastIdx = i + 1
140		}
141	}
142	if count < 2 {
143		t.Errorf("expected GIT_TAG to appear twice (default + override), got %d", count)
144	}
145	if lastIdx < 0 || args[lastIdx] != "GIT_TAG="+userArgs[0][len("GIT_TAG="):] {
146		t.Errorf("expected last GIT_TAG= arg to be the user override %q, got %q", "GIT_TAG=custom-override", args[lastIdx])
147	}
148}
149
150func TestParseTagRef(t *testing.T) {
151	tests := []struct {
152		name string
153		ref  string
154		want string
155	}{
156		{name: "github tag ref", ref: "refs/tags/v1.2.3", want: "v1.2.3"},
157		{name: "peeled tag ref", ref: "refs/tags/v1.2.3^{}", want: "v1.2.3"},
158		{name: "branch ref", ref: "refs/heads/main", want: ""},
159		{name: "empty", ref: "", want: ""},
160	}
161
162	for _, tt := range tests {
163		t.Run(tt.name, func(t *testing.T) {
164			assert.Equal(t, tt.want, parseTagRef(tt.ref))
165		})
166	}
167}
168
169func TestVersionFromTagRefs(t *testing.T) {
170	tests := []struct {
171		name string
172		env  map[string]string
173		want string
174	}{
175		{
176			name: "github ref tag",
177			env:  map[string]string{"GITHUB_REF": "refs/tags/v2.0.0"},
178			want: "v2.0.0",
179		},
180		{
181			name: "github ref type and name",
182			env: map[string]string{
183				"GITHUB_REF_TYPE": "tag",
184				"GITHUB_REF_NAME": "v2.1.0",
185			},
186			want: "v2.1.0",
187		},
188		{
189			name: "gitlab commit tag",
190			env:  map[string]string{"CI_COMMIT_TAG": "v3.0.0"},
191			want: "v3.0.0",
192		},
193		{
194			name: "azure source branch tag ref",
195			env:  map[string]string{"BUILD_SOURCEBRANCH": "refs/tags/v4.0.0"},
196			want: "v4.0.0",
197		},
198		{
199			name: "no tag refs",
200			env:  map[string]string{"GITHUB_REF": "refs/heads/main"},
201			want: "",
202		},
203	}
204
205	for _, tt := range tests {
206		t.Run(tt.name, func(t *testing.T) {
207			got := versionFromTagRefs(func(key string) string {
208				return tt.env[key]
209			})
210			assert.Equal(t, tt.want, got)
211		})
212	}
213}
214
215func TestBuildImageArgs_CustomDockerfile(t *testing.T) {
216	args := buildImageArgs(context.Background(), "v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest")
217
218	assert.Contains(t, args, "-f")
219	assert.Contains(t, args, "docker/app/Dockerfile")
220}
221
222func TestParseDockerfileLabels(t *testing.T) {
223	tests := []struct {
224		name     string
225		content  string
226		expected map[string]string
227	}{
228		{
229			name:    "simple label",
230			content: `LABEL gordon.domain="myapp.example.com"`,
231			expected: map[string]string{
232				"gordon.domain": "myapp.example.com",
233			},
234		},
235		{
236			name: "multiple labels",
237			content: `LABEL gordon.domain="myapp.example.com"
238LABEL gordon.proxy.port="8080"
239LABEL gordon.health="/health"`,
240			expected: map[string]string{
241				"gordon.domain":     "myapp.example.com",
242				"gordon.proxy.port": "8080",
243				"gordon.health":     "/health",
244			},
245		},
246		{
247			name:    "multi-label on one line",
248			content: `LABEL gordon.domain="myapp.example.com" gordon.proxy.port="8080"`,
249			expected: map[string]string{
250				"gordon.domain":     "myapp.example.com",
251				"gordon.proxy.port": "8080",
252			},
253		},
254		{
255			name:    "unquoted value",
256			content: `LABEL gordon.domain=myapp.example.com`,
257			expected: map[string]string{
258				"gordon.domain": "myapp.example.com",
259			},
260		},
261		{
262			name:    "single-quoted value",
263			content: `LABEL gordon.domain='myapp.example.com'`,
264			expected: map[string]string{
265				"gordon.domain": "myapp.example.com",
266			},
267		},
268		{
269			name:    "non-gordon labels ignored",
270			content: `LABEL maintainer="John" gordon.domain="myapp.example.com"`,
271			expected: map[string]string{
272				"gordon.domain": "myapp.example.com",
273			},
274		},
275		{
276			name:    "comma-separated domains",
277			content: `LABEL gordon.domains="app1.example.com,app2.example.com"`,
278			expected: map[string]string{
279				"gordon.domains": "app1.example.com,app2.example.com",
280			},
281		},
282		{
283			name:     "no labels",
284			content:  `FROM alpine:latest`,
285			expected: map[string]string{},
286		},
287		{
288			name:     "comments and empty lines",
289			content:  "# This is a comment\n\nLABEL gordon.domain=\"test.com\"",
290			expected: map[string]string{"gordon.domain": "test.com"},
291		},
292	}
293
294	for _, tt := range tests {
295		t.Run(tt.name, func(t *testing.T) {
296			// Write a temp Dockerfile
297			dir := t.TempDir()
298			dockerfile := filepath.Join(dir, "Dockerfile")
299			err := os.WriteFile(dockerfile, []byte(tt.content), 0644)
300			assert.NoError(t, err)
301
302			labels := parseDockerfileLabels(dockerfile)
303			assert.Equal(t, tt.expected, labels)
304		})
305	}
306}
307
308func TestParseDockerfileLabels_NonExistent(t *testing.T) {
309	labels := parseDockerfileLabels("/nonexistent/Dockerfile")
310	assert.Empty(t, labels)
311}
312
313func TestDetectImageName_FromDockerfileLabels(t *testing.T) {
314	dir := t.TempDir()
315	dockerfile := filepath.Join(dir, "Dockerfile")
316	err := os.WriteFile(dockerfile, []byte(`FROM alpine
317LABEL gordon.domain="myapp.example.com"
318`), 0644)
319	assert.NoError(t, err)
320
321	name, err := detectImageName(dockerfile)
322	assert.NoError(t, err)
323	assert.Equal(t, "myapp.example.com", name)
324}
325
326func TestDetectImageName_FallbackToDir(t *testing.T) {
327	dir := t.TempDir()
328	dockerfile := filepath.Join(dir, "Dockerfile")
329	err := os.WriteFile(dockerfile, []byte(`FROM alpine
330`), 0644)
331	assert.NoError(t, err)
332
333	// When no gordon.domain label, falls back to cwd basename
334	name, err := detectImageName(dockerfile)
335	assert.NoError(t, err)
336	assert.NotEmpty(t, name)
337}
338
339func TestSplitLabelPairs(t *testing.T) {
340	tests := []struct {
341		name     string
342		content  string
343		expected []string
344	}{
345		{
346			name:     "single pair",
347			content:  `gordon.domain="test.com"`,
348			expected: []string{`gordon.domain="test.com"`},
349		},
350		{
351			name:     "two pairs",
352			content:  `gordon.domain="test.com" gordon.proxy.port="8080"`,
353			expected: []string{`gordon.domain="test.com"`, `gordon.proxy.port="8080"`},
354		},
355		{
356			name:     "unquoted",
357			content:  `gordon.domain=test.com gordon.proxy.port=8080`,
358			expected: []string{`gordon.domain=test.com`, `gordon.proxy.port=8080`},
359		},
360		{
361			name:     "quoted with spaces in value",
362			content:  `gordon.domain="my app.com"`,
363			expected: []string{`gordon.domain="my app.com"`},
364		},
365	}
366
367	for _, tt := range tests {
368		t.Run(tt.name, func(t *testing.T) {
369			result := splitLabelPairs(tt.content)
370			assert.Equal(t, tt.expected, result)
371		})
372	}
373}
374
375func TestGetGitVersionNoTagsReturnsFallbackAndWarns(t *testing.T) {
376	ctx := context.Background()
377
378	// Create a temp dir with a git repo that has no tags
379	tmpDir := t.TempDir()
380	if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil { // #nosec G204
381		t.Skipf("git init failed: %v", err)
382	}
383	// Need at least one commit so git describe has something to describe
384	if err := exec.Command("git", "-C", tmpDir, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "init").Run(); err != nil { // #nosec G204
385		t.Skipf("git commit failed: %v", err)
386	}
387
388	// Change to the tmpDir for this test so getGitVersion uses the tag-less repo
389	origDir, err := os.Getwd()
390	if err != nil {
391		t.Fatal(err)
392	}
393	if err := os.Chdir(tmpDir); err != nil { // #nosec G204
394		t.Fatal(err)
395	}
396	defer os.Chdir(origDir) // #nosec G204
397
398	// Redirect stderr to capture the warning
399	origStderr := os.Stderr
400	r, w, err := os.Pipe()
401	if err != nil {
402		t.Fatal(err)
403	}
404	os.Stderr = w
405	defer func() {
406		w.Close()
407		os.Stderr = origStderr
408	}()
409
410	v := getGitVersion(ctx)
411
412	w.Close()
413	os.Stderr = origStderr
414	stderrOutput, _ := io.ReadAll(r)
415
416	// Should return "" (fallback to "latest" handled by determineVersion)
417	assert.Equal(t, "", v, "expected empty string fallback when no git tags exist")
418
419	// Should have printed a warning to stderr
420	assert.Contains(t, string(stderrOutput), "latest",
421		"expected a warning about 'latest' fallback on stderr")
422}
423
424func TestParseLabelPair(t *testing.T) {
425	tests := []struct {
426		name      string
427		pair      string
428		wantKey   string
429		wantValue string
430		wantOk    bool
431	}{
432		{"quoted", `gordon.domain="test.com"`, "gordon.domain", "test.com", true},
433		{"unquoted", `gordon.domain=test.com`, "gordon.domain", "test.com", true},
434		{"single quoted", `gordon.domain='test.com'`, "gordon.domain", "test.com", true},
435		{"empty value", `gordon.domain=`, "gordon.domain", "", true},
436		{"no equals", `gordon.domain`, "", "", false},
437	}
438
439	for _, tt := range tests {
440		t.Run(tt.name, func(t *testing.T) {
441			key, value, ok := parseLabelPair(tt.pair)
442			assert.Equal(t, tt.wantOk, ok)
443			if ok {
444				assert.Equal(t, tt.wantKey, key)
445				assert.Equal(t, tt.wantValue, value)
446			}
447		})
448	}
449}

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