main 8d5c184

images_test.go RawHistoryFolder

1package cli
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"regexp"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14
15	"github.com/bnema/gordon/internal/adapters/dto"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19type imagesClientMock struct {
20	listImagesResp []dto.Image
21	listImagesErr  error
22	pruneResp      *dto.ImagePruneResponse
23	pruneErr       error
24
25	listImagesCalls int
26	pruneCalls      int
27	lastPruneOpts   dto.ImagePruneRequest
28}
29
30func (f *imagesClientMock) ListImages(_ context.Context) ([]dto.Image, error) {
31	f.listImagesCalls++
32	if f.listImagesErr != nil {
33		return nil, f.listImagesErr
34	}
35	return f.listImagesResp, nil
36}
37
38func (f *imagesClientMock) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
39	f.pruneCalls++
40	f.lastPruneOpts = req
41	if f.pruneErr != nil {
42		return nil, f.pruneErr
43	}
44	return f.pruneResp, nil
45}
46
47// ---------------------------------------------------------------------------
48// images list tests (unchanged)
49// ---------------------------------------------------------------------------
50
51func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
52	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
53	client := &imagesClientMock{
54		listImagesResp: []dto.Image{
55			{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
56			{Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true},
57		},
58	}
59
60	var out bytes.Buffer
61	err := runImagesList(context.Background(), client, &out, false)
62	require.NoError(t, err)
63
64	text := out.String()
65	assert.Contains(t, text, "REPOSITORY")
66	assert.Contains(t, text, "registry.example.com/app")
67	assert.Contains(t, text, "latest")
68	assert.Contains(t, text, "Total images: 2 (dangling: 1)")
69	assert.Equal(t, 1, client.listImagesCalls)
70}
71
72func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
73	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
74	client := &imagesClientMock{
75		listImagesResp: []dto.Image{
76			{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
77		},
78	}
79
80	var out bytes.Buffer
81	err := runImagesList(context.Background(), client, &out, false)
82	require.NoError(t, err)
83
84	text := stripANSI(out.String())
85	header := findHeaderLine(text)
86	assert.NotEmpty(t, header)
87	assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
88	assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
89	assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
90	assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
91	assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
92	assert.Contains(t, text, "Total images: 1 (dangling: 0)")
93}
94
95func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) {
96	createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
97	longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use"
98	longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow"
99	client := &imagesClientMock{
100		listImagesResp: []dto.Image{
101			{Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false},
102		},
103	}
104
105	var out bytes.Buffer
106	err := runImagesList(context.Background(), client, &out, false)
107	require.NoError(t, err)
108
109	text := stripANSI(out.String())
110	header := findHeaderLine(text)
111	assert.NotEmpty(t, header)
112	assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG"))
113	assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE"))
114	assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED"))
115	assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID"))
116	assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING"))
117
118	row := findImageListRowLine(text, "latest")
119	assert.NotEmpty(t, row)
120
121	cols := splitTableColumns(row)
122	require.Len(t, cols, 6)
123
124	repositoryField := cols[0]
125	imageIDField := cols[4]
126
127	assert.NotEqual(t, longRepository, repositoryField)
128	assert.NotEqual(t, longImageID, imageIDField)
129	assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
130	assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
131	assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
132	assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
133
134	assert.Contains(t, text, "Total images: 1 (dangling: 0)")
135}
136
137func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) {
138	longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra"
139	longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name"
140	longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
141	client := &imagesClientMock{
142		listImagesResp: []dto.Image{
143			{Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true},
144		},
145	}
146
147	var out bytes.Buffer
148	err := runImagesList(context.Background(), client, &out, false)
149	require.NoError(t, err)
150
151	text := stripANSI(out.String())
152	rows := findImageListRows(text)
153	require.Len(t, rows, 1, "expected a single, no-wrap data row")
154
155	cols := rows[0]
156	require.Len(t, cols, 6)
157
158	repositoryField := cols[0]
159	tagField := cols[1]
160	sizeField := cols[2]
161	createdField := cols[3]
162	imageIDField := cols[4]
163
164	assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis")
165	assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis")
166	assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis")
167	assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "...")))
168	assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "...")))
169	assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "...")))
170
171	assert.Equal(t, "0 B", sizeField)
172	assert.Equal(t, "-", createdField)
173	assert.Contains(t, text, "Total images: 1 (dangling: 1)")
174}
175
176func TestRunImagesList_EmptyOutput(t *testing.T) {
177	client := &imagesClientMock{listImagesResp: []dto.Image{}}
178
179	var out bytes.Buffer
180	err := runImagesList(context.Background(), client, &out, false)
181	require.NoError(t, err)
182
183	text := out.String()
184	assert.Contains(t, text, "No images found")
185	assert.Contains(t, text, "Total images: 0")
186	assert.Equal(t, 1, client.listImagesCalls)
187}
188
189// ---------------------------------------------------------------------------
190// images prune: default scope behavior
191// ---------------------------------------------------------------------------
192
193func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) {
194	// No scope flags → both scopes enabled, keep-releases defaults to domain default.
195	client := &imagesClientMock{
196		pruneResp: &dto.ImagePruneResponse{
197			Runtime:  dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512},
198			Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024},
199		},
200	}
201
202	var out bytes.Buffer
203	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
204	err := runImagesPrune(context.Background(), client, opts, &out)
205	require.NoError(t, err)
206
207	assert.Equal(t, 1, client.pruneCalls)
208	require.NotNil(t, client.lastPruneOpts.KeepLast)
209	assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast)
210	require.NotNil(t, client.lastPruneOpts.PruneDangling)
211	assert.True(t, *client.lastPruneOpts.PruneDangling)
212	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
213	assert.True(t, *client.lastPruneOpts.PruneRegistry)
214
215	text := out.String()
216	assert.Contains(t, text, "Runtime: deleted=1")
217	assert.Contains(t, text, "Registry: tags_removed=2")
218}
219
220func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) {
221	client := &imagesClientMock{
222		pruneResp: &dto.ImagePruneResponse{
223			Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048},
224		},
225	}
226
227	var out bytes.Buffer
228	opts := imagesPruneOptions{Dangling: true, NoConfirm: true}
229	err := runImagesPrune(context.Background(), client, opts, &out)
230	require.NoError(t, err)
231
232	assert.Equal(t, 1, client.pruneCalls)
233	require.NotNil(t, client.lastPruneOpts.PruneDangling)
234	assert.True(t, *client.lastPruneOpts.PruneDangling)
235	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
236	assert.False(t, *client.lastPruneOpts.PruneRegistry)
237
238	text := out.String()
239	assert.Contains(t, text, "Runtime: deleted=3")
240	assert.Contains(t, text, "Registry cleanup skipped")
241}
242
243func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) {
244	client := &imagesClientMock{
245		pruneResp: &dto.ImagePruneResponse{
246			Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096},
247		},
248	}
249
250	var out bytes.Buffer
251	opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true}
252	err := runImagesPrune(context.Background(), client, opts, &out)
253	require.NoError(t, err)
254
255	assert.Equal(t, 1, client.pruneCalls)
256	require.NotNil(t, client.lastPruneOpts.PruneDangling)
257	assert.False(t, *client.lastPruneOpts.PruneDangling)
258	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
259	assert.True(t, *client.lastPruneOpts.PruneRegistry)
260	require.NotNil(t, client.lastPruneOpts.KeepLast)
261	assert.Equal(t, 5, *client.lastPruneOpts.KeepLast)
262
263	text := out.String()
264	assert.Contains(t, text, "Runtime cleanup skipped")
265	assert.Contains(t, text, "Registry: tags_removed=4")
266}
267
268func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) {
269	client := &imagesClientMock{
270		pruneResp: &dto.ImagePruneResponse{
271			Runtime:  dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100},
272			Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200},
273		},
274	}
275
276	var out bytes.Buffer
277	opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true}
278	err := runImagesPrune(context.Background(), client, opts, &out)
279	require.NoError(t, err)
280
281	assert.Equal(t, 1, client.pruneCalls)
282	require.NotNil(t, client.lastPruneOpts.PruneDangling)
283	assert.True(t, *client.lastPruneOpts.PruneDangling)
284	require.NotNil(t, client.lastPruneOpts.PruneRegistry)
285	assert.True(t, *client.lastPruneOpts.PruneRegistry)
286	require.NotNil(t, client.lastPruneOpts.KeepLast)
287	assert.Equal(t, 2, *client.lastPruneOpts.KeepLast)
288
289	text := out.String()
290	assert.Contains(t, text, "Runtime: deleted=1")
291	assert.Contains(t, text, "Registry: tags_removed=1")
292}
293
294// ---------------------------------------------------------------------------
295// images prune: dry-run with scope flags
296// ---------------------------------------------------------------------------
297
298func TestRunImagesPrune_DryRunBothScopes(t *testing.T) {
299	client := &imagesClientMock{
300		pruneResp: &dto.ImagePruneResponse{
301			Plan: dto.PruneSummary{
302				Applied:   false,
303				Eligible:  2,
304				Protected: 1,
305				Candidates: []dto.PruneCandidate{
306					{Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"},
307					{Kind: "runtime-image", Ref: "sha256:b", Verdict: "eligible"},
308					{Kind: "registry-tag", Ref: "app:v1", Verdict: "protected", Reasons: []string{"protected-active-service"}},
309				},
310			},
311		},
312	}
313
314	var out bytes.Buffer
315	opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
316	err := runImagesPrune(context.Background(), client, opts, &out)
317	require.NoError(t, err)
318
319	text := out.String()
320	assert.Contains(t, text, "Dry run")
321	assert.Contains(t, text, "would delete 2 images")
322	assert.Contains(t, text, "latest + 3 previous tags")
323	assert.Contains(t, text, "Would delete: 2 (eligible=2 protected=1 unknown=0)")
324	assert.Contains(t, text, "protected-active-service")
325	assert.Equal(t, 1, client.pruneCalls)
326	assert.NotNil(t, client.lastPruneOpts.DryRun)
327	assert.True(t, *client.lastPruneOpts.DryRun)
328}
329
330func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) {
331	client := &imagesClientMock{
332		pruneResp: &dto.ImagePruneResponse{
333			Plan: dto.PruneSummary{
334				Candidates: []dto.PruneCandidate{{Kind: "runtime-image", Ref: "sha256:a", Verdict: "eligible"}},
335			},
336		},
337	}
338
339	var out bytes.Buffer
340	opts := imagesPruneOptions{DryRun: true, Dangling: true}
341	err := runImagesPrune(context.Background(), client, opts, &out)
342	require.NoError(t, err)
343
344	text := out.String()
345	assert.Contains(t, text, "would delete 1 images")
346	assert.Contains(t, text, "Registry cleanup skipped")
347	assert.Equal(t, 1, client.pruneCalls)
348}
349
350func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) {
351	client := &imagesClientMock{
352		pruneResp: &dto.ImagePruneResponse{
353			Plan: dto.PruneSummary{
354				Candidates: []dto.PruneCandidate{{Kind: "registry-tag", Ref: "app:v1", Verdict: "eligible"}},
355			},
356		},
357	}
358
359	var out bytes.Buffer
360	opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5}
361	err := runImagesPrune(context.Background(), client, opts, &out)
362	require.NoError(t, err)
363
364	text := out.String()
365	assert.Contains(t, text, "Runtime cleanup skipped")
366	assert.Contains(t, text, "latest + 5 previous tags")
367	assert.Equal(t, 1, client.pruneCalls)
368}
369
370// ---------------------------------------------------------------------------
371// images prune: keep-releases semantics
372// ---------------------------------------------------------------------------
373
374func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) {
375	client := &imagesClientMock{
376		pruneResp: &dto.ImagePruneResponse{
377			Runtime:  dto.RuntimePruneResult{DeletedCount: 0},
378			Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000},
379		},
380	}
381
382	var out bytes.Buffer
383	opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true}
384	err := runImagesPrune(context.Background(), client, opts, &out)
385	require.NoError(t, err)
386
387	require.NotNil(t, client.lastPruneOpts.KeepLast)
388	assert.Equal(t, 10, *client.lastPruneOpts.KeepLast)
389}
390
391func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) {
392	err := runImagesPrune(context.Background(), &imagesClientMock{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{})
393	require.Error(t, err)
394	assert.Contains(t, err.Error(), "--keep-releases must be >= 0")
395}
396
397// ---------------------------------------------------------------------------
398// images prune: confirmation behavior
399// ---------------------------------------------------------------------------
400
401func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
402	confirmCalled := false
403	origConfirm := pruneConfirmFunc
404	pruneConfirmFunc = func(_ string) (bool, error) {
405		confirmCalled = true
406		return true, nil
407	}
408	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
409
410	client := &imagesClientMock{
411		pruneResp: &dto.ImagePruneResponse{
412			Runtime:  dto.RuntimePruneResult{DeletedCount: 1},
413			Registry: dto.RegistryPruneResult{TagsRemoved: 1},
414		},
415	}
416
417	var out bytes.Buffer
418	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
419	err := runImagesPrune(context.Background(), client, opts, &out)
420	require.NoError(t, err)
421
422	assert.True(t, confirmCalled, "confirmation should have been called")
423	assert.Equal(t, 1, client.pruneCalls)
424}
425
426func TestRunImagesPrune_ConfirmPromptIncludesScopePreviewCounts(t *testing.T) {
427	created := time.Date(2026, 2, 13, 7, 0, 0, 0, time.UTC)
428	var prompt string
429	origConfirm := pruneConfirmFunc
430	pruneConfirmFunc = func(value string) (bool, error) {
431		prompt = value
432		return false, nil
433	}
434	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
435
436	client := &imagesClientMock{
437		listImagesResp: []dto.Image{
438			{Repository: "registry.example.com/app", Tag: "latest", Created: created},
439			{Repository: "registry.example.com/app", Tag: "v3", Created: created.Add(-1 * time.Hour)},
440			{Repository: "registry.example.com/app", Tag: "v2", Created: created.Add(-2 * time.Hour)},
441			{Repository: "registry.example.com/app", Tag: "v1", Created: created.Add(-3 * time.Hour)},
442			{Repository: "<none>", Tag: "<none>", Dangling: true},
443		},
444	}
445
446	var out bytes.Buffer
447	opts := imagesPruneOptions{KeepReleases: 2}
448	err := runImagesPrune(context.Background(), client, opts, &out)
449	require.NoError(t, err)
450
451	assert.Contains(t, prompt, "Runtime: would prune 1 dangling runtime images")
452	assert.Contains(t, prompt, "Registry: would prune 1 tags")
453	assert.Equal(t, 1, client.listImagesCalls)
454	assert.Equal(t, 0, client.pruneCalls)
455}
456
457func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
458	origConfirm := pruneConfirmFunc
459	pruneConfirmFunc = func(_ string) (bool, error) {
460		return false, nil
461	}
462	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
463
464	client := &imagesClientMock{pruneResp: &dto.ImagePruneResponse{}}
465
466	var out bytes.Buffer
467	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast}
468	err := runImagesPrune(context.Background(), client, opts, &out)
469	require.NoError(t, err)
470
471	assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection")
472	assert.Contains(t, out.String(), "cancelled")
473}
474
475func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) {
476	confirmCalled := false
477	origConfirm := pruneConfirmFunc
478	pruneConfirmFunc = func(_ string) (bool, error) {
479		confirmCalled = true
480		return true, nil
481	}
482	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
483
484	client := &imagesClientMock{
485		pruneResp: &dto.ImagePruneResponse{
486			Runtime: dto.RuntimePruneResult{DeletedCount: 1},
487		},
488	}
489
490	var out bytes.Buffer
491	opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true}
492	err := runImagesPrune(context.Background(), client, opts, &out)
493	require.NoError(t, err)
494
495	assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm")
496	assert.Equal(t, 1, client.pruneCalls)
497}
498
499func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) {
500	confirmCalled := false
501	origConfirm := pruneConfirmFunc
502	pruneConfirmFunc = func(_ string) (bool, error) {
503		confirmCalled = true
504		return true, nil
505	}
506	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
507
508	client := &imagesClientMock{
509		pruneResp: &dto.ImagePruneResponse{Plan: dto.PruneSummary{}},
510	}
511
512	var out bytes.Buffer
513	opts := imagesPruneOptions{DryRun: true, KeepReleases: 3}
514	err := runImagesPrune(context.Background(), client, opts, &out)
515	require.NoError(t, err)
516
517	assert.False(t, confirmCalled, "confirmation should not be called during dry-run")
518	assert.Equal(t, 1, client.pruneCalls)
519}
520
521// ---------------------------------------------------------------------------
522// images prune: error handling
523// ---------------------------------------------------------------------------
524
525func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
526	origConfirm := pruneConfirmFunc
527	pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
528	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
529
530	client := &imagesClientMock{pruneErr: errors.New("request failed")}
531
532	err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{})
533	require.Error(t, err)
534	assert.Contains(t, err.Error(), "failed to prune images")
535}
536
537func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
538	origConfirm := pruneConfirmFunc
539	pruneConfirmFunc = func(_ string) (bool, error) { return true, nil }
540	t.Cleanup(func() { pruneConfirmFunc = origConfirm })
541
542	client := &imagesClientMock{pruneResp: nil}
543	err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{})
544	require.Error(t, err)
545	assert.Contains(t, err.Error(), "empty response")
546}
547
548// ---------------------------------------------------------------------------
549// test helpers
550// ---------------------------------------------------------------------------
551
552func stripANSI(value string) string {
553	ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
554	return ansiRegex.ReplaceAllString(value, "")
555}
556
557func findHeaderLine(text string) string {
558	for line := range strings.SplitSeq(text, "\n") {
559		if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") {
560			return line
561		}
562	}
563
564	return ""
565}
566
567func findImageListRowLine(text string, tag string) string {
568	for line := range strings.SplitSeq(text, "\n") {
569		trimmed := strings.TrimSpace(line)
570		if trimmed == "" {
571			continue
572		}
573		if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
574			continue
575		}
576
577		cols := splitTableColumns(line)
578		if len(cols) == 6 && cols[1] == tag {
579			return line
580		}
581	}
582
583	return ""
584}
585
586func findImageListRows(text string) [][]string {
587	rows := make([][]string, 0)
588	for line := range strings.SplitSeq(text, "\n") {
589		trimmed := strings.TrimSpace(line)
590		if trimmed == "" {
591			continue
592		}
593		if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") {
594			continue
595		}
596
597		cols := splitTableColumns(line)
598		if len(cols) == 6 && cols[0] != "REPOSITORY" {
599			rows = append(rows, cols)
600		}
601	}
602
603	return rows
604}
605
606func splitTableColumns(line string) []string {
607	if strings.Contains(line, "│") {
608		parts := strings.Split(line, "│")
609		cols := make([]string, 0, len(parts))
610		for _, part := range parts {
611			cell := strings.TrimSpace(part)
612			if cell != "" {
613				cols = append(cols, cell)
614			}
615		}
616		return cols
617	}
618
619	return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1)
620}

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