v3.0.0 f19f453

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

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