detached 3567896

fix(cli): surface unresolved route targets

commit
356789647a467bae7281f8c899c842fa4caf351b
author
brice · · 2026-04-14 18:10 UTC
parent
a0c8f05
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +106 -1
M internal/adapters/in/cli/controlplane_resolver.go +0 -1
@@ -32,7 +32,6 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
3232 }
3333
3434 func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35-
3635 kernel, err := app.NewKernel(configPath)
3736 if err != nil {
3837 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
@@ -32,7 +32,6 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
32 }32 }
33 33
34 func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {34 func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35-
36 kernel, err := app.NewKernel(configPath)35 kernel, err := app.NewKernel(configPath)
37 if err != nil {36 if err != nil {
38 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)37 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
M internal/adapters/in/cli/routes.go +18 -0
@@ -269,6 +269,13 @@ func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesL
269269 section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
270270 return []routeListSection{section}, nil
271271 }
272+ if target, ok := resolveRoutesExplicitTarget(); ok {
273+ return []routeListSection{{
274+ Kind: "remote",
275+ Name: target,
276+ Error: unresolvedRoutesTargetError(target),
277+ }}, nil
278+ }
272279
273280 return collectRoutesListAggregateSections(ctx, cfgPath, deps)
274281 }
@@ -371,6 +378,13 @@ func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps route
371378 section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
372379 return []routeStatusSection{section}, nil
373380 }
381+ if target, ok := resolveRoutesExplicitTarget(); ok {
382+ return []routeStatusSection{{
383+ Kind: "remote",
384+ Name: target,
385+ Error: unresolvedRoutesTargetError(target),
386+ }}, nil
387+ }
374388
375389 return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
376390 }
@@ -585,6 +599,10 @@ func capitalizeKind(kind string) string {
585599 return strings.ToUpper(kind[:1]) + kind[1:]
586600 }
587601
602+func unresolvedRoutesTargetError(target string) string {
603+ return fmt.Sprintf("could not resolve remote target %q", target)
604+}
605+
588606 func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool) {
589607 target, ok := resolveRoutesExplicitTarget()
590608 if !ok {
@@ -269,6 +269,13 @@ func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesL
269 section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)269 section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
270 return []routeListSection{section}, nil270 return []routeListSection{section}, nil
271 }271 }
272+ if target, ok := resolveRoutesExplicitTarget(); ok {
273+ return []routeListSection{{
274+ Kind: "remote",
275+ Name: target,
276+ Error: unresolvedRoutesTargetError(target),
277+ }}, nil
278+ }
272 279
273 return collectRoutesListAggregateSections(ctx, cfgPath, deps)280 return collectRoutesListAggregateSections(ctx, cfgPath, deps)
274 }281 }
@@ -371,6 +378,13 @@ func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps route
371 section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)378 section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
372 return []routeStatusSection{section}, nil379 return []routeStatusSection{section}, nil
373 }380 }
381+ if target, ok := resolveRoutesExplicitTarget(); ok {
382+ return []routeStatusSection{{
383+ Kind: "remote",
384+ Name: target,
385+ Error: unresolvedRoutesTargetError(target),
386+ }}, nil
387+ }
374 388
375 return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)389 return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
376 }390 }
@@ -585,6 +599,10 @@ func capitalizeKind(kind string) string {
585 return strings.ToUpper(kind[:1]) + kind[1:]599 return strings.ToUpper(kind[:1]) + kind[1:]
586 }600 }
587 601
602+func unresolvedRoutesTargetError(target string) string {
603+ return fmt.Sprintf("could not resolve remote target %q", target)
604+}
605+
588 func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool) {606 func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool) {
589 target, ok := resolveRoutesExplicitTarget()607 target, ok := resolveRoutesExplicitTarget()
590 if !ok {608 if !ok {
M internal/adapters/in/cli/routes_test.go +88 -0
@@ -6,6 +6,7 @@ import (
66 "os"
77 "path/filepath"
88 "strings"
9+ "sync/atomic"
910 "testing"
1011
1112 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -340,6 +341,93 @@ func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
340341 assert.Equal(t, "test.supri.xyz", sections[0].Routes[0].Domain)
341342 }
342343
344+func TestCollectRoutesSections_ExplicitUnknownNamedTargetReturnsErrorSection(t *testing.T) {
345+ originalRemoteFlag := remoteFlag
346+ originalTokenFlag := tokenFlag
347+ originalInsecureTLSFlag := insecureTLSFlag
348+ t.Cleanup(func() {
349+ remoteFlag = originalRemoteFlag
350+ tokenFlag = originalTokenFlag
351+ insecureTLSFlag = originalInsecureTLSFlag
352+ })
353+
354+ remoteFlag = "missing-remote"
355+ tokenFlag = ""
356+ insecureTLSFlag = false
357+ configHome := t.TempDir()
358+ t.Setenv("XDG_CONFIG_HOME", configHome)
359+ t.Setenv("HOME", t.TempDir())
360+ t.Setenv("GORDON_REMOTE", "")
361+
362+ t.Run("list", func(t *testing.T) {
363+ var loadLocalCalls atomic.Int32
364+ var listRemotesCalls atomic.Int32
365+ var loadRemoteCalls atomic.Int32
366+
367+ deps := routesListDeps{
368+ explicitRemote: resolveRoutesExplicitRemote,
369+ loadLocal: func(context.Context, string) (routeListSection, error) {
370+ loadLocalCalls.Add(1)
371+ return routeListSection{}, nil
372+ },
373+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
374+ listRemotesCalls.Add(1)
375+ return map[string]remote.RemoteEntry{}, "", nil
376+ },
377+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
378+ loadRemoteCalls.Add(1)
379+ return routeListSection{}, nil
380+ },
381+ }
382+
383+ sections, err := collectRoutesListSections(context.Background(), "", deps)
384+
385+ require.NoError(t, err)
386+ require.Len(t, sections, 1)
387+ assert.Equal(t, "remote", sections[0].Kind)
388+ assert.Equal(t, "missing-remote", sections[0].Name)
389+ require.NotEmpty(t, sections[0].Error)
390+ assert.Contains(t, sections[0].Error, "missing-remote")
391+ assert.Zero(t, loadLocalCalls.Load())
392+ assert.Zero(t, listRemotesCalls.Load())
393+ assert.Zero(t, loadRemoteCalls.Load())
394+ })
395+
396+ t.Run("status", func(t *testing.T) {
397+ var loadLocalCalls atomic.Int32
398+ var listRemotesCalls atomic.Int32
399+ var loadRemoteCalls atomic.Int32
400+
401+ deps := routesStatusDeps{
402+ explicitRemote: resolveRoutesExplicitRemote,
403+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
404+ loadLocalCalls.Add(1)
405+ return routeStatusSection{}, nil
406+ },
407+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
408+ listRemotesCalls.Add(1)
409+ return map[string]remote.RemoteEntry{}, "", nil
410+ },
411+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
412+ loadRemoteCalls.Add(1)
413+ return routeStatusSection{}, nil
414+ },
415+ }
416+
417+ sections, err := collectRoutesStatusSections(context.Background(), "", deps)
418+
419+ require.NoError(t, err)
420+ require.Len(t, sections, 1)
421+ assert.Equal(t, "remote", sections[0].Kind)
422+ assert.Equal(t, "missing-remote", sections[0].Name)
423+ require.NotEmpty(t, sections[0].Error)
424+ assert.Contains(t, sections[0].Error, "missing-remote")
425+ assert.Zero(t, loadLocalCalls.Load())
426+ assert.Zero(t, listRemotesCalls.Load())
427+ assert.Zero(t, loadRemoteCalls.Load())
428+ })
429+}
430+
343431 func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
344432 sections := []routeStatusSection{
345433 {
@@ -6,6 +6,7 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9+ "sync/atomic"
9 "testing"10 "testing"
10 11
11 "github.com/bnema/gordon/internal/adapters/in/cli/remote"12 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -340,6 +341,93 @@ func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
340 assert.Equal(t, "test.supri.xyz", sections[0].Routes[0].Domain)341 assert.Equal(t, "test.supri.xyz", sections[0].Routes[0].Domain)
341 }342 }
342 343
344+func TestCollectRoutesSections_ExplicitUnknownNamedTargetReturnsErrorSection(t *testing.T) {
345+ originalRemoteFlag := remoteFlag
346+ originalTokenFlag := tokenFlag
347+ originalInsecureTLSFlag := insecureTLSFlag
348+ t.Cleanup(func() {
349+ remoteFlag = originalRemoteFlag
350+ tokenFlag = originalTokenFlag
351+ insecureTLSFlag = originalInsecureTLSFlag
352+ })
353+
354+ remoteFlag = "missing-remote"
355+ tokenFlag = ""
356+ insecureTLSFlag = false
357+ configHome := t.TempDir()
358+ t.Setenv("XDG_CONFIG_HOME", configHome)
359+ t.Setenv("HOME", t.TempDir())
360+ t.Setenv("GORDON_REMOTE", "")
361+
362+ t.Run("list", func(t *testing.T) {
363+ var loadLocalCalls atomic.Int32
364+ var listRemotesCalls atomic.Int32
365+ var loadRemoteCalls atomic.Int32
366+
367+ deps := routesListDeps{
368+ explicitRemote: resolveRoutesExplicitRemote,
369+ loadLocal: func(context.Context, string) (routeListSection, error) {
370+ loadLocalCalls.Add(1)
371+ return routeListSection{}, nil
372+ },
373+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
374+ listRemotesCalls.Add(1)
375+ return map[string]remote.RemoteEntry{}, "", nil
376+ },
377+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeListSection, error) {
378+ loadRemoteCalls.Add(1)
379+ return routeListSection{}, nil
380+ },
381+ }
382+
383+ sections, err := collectRoutesListSections(context.Background(), "", deps)
384+
385+ require.NoError(t, err)
386+ require.Len(t, sections, 1)
387+ assert.Equal(t, "remote", sections[0].Kind)
388+ assert.Equal(t, "missing-remote", sections[0].Name)
389+ require.NotEmpty(t, sections[0].Error)
390+ assert.Contains(t, sections[0].Error, "missing-remote")
391+ assert.Zero(t, loadLocalCalls.Load())
392+ assert.Zero(t, listRemotesCalls.Load())
393+ assert.Zero(t, loadRemoteCalls.Load())
394+ })
395+
396+ t.Run("status", func(t *testing.T) {
397+ var loadLocalCalls atomic.Int32
398+ var listRemotesCalls atomic.Int32
399+ var loadRemoteCalls atomic.Int32
400+
401+ deps := routesStatusDeps{
402+ explicitRemote: resolveRoutesExplicitRemote,
403+ loadLocal: func(context.Context, string) (routeStatusSection, error) {
404+ loadLocalCalls.Add(1)
405+ return routeStatusSection{}, nil
406+ },
407+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
408+ listRemotesCalls.Add(1)
409+ return map[string]remote.RemoteEntry{}, "", nil
410+ },
411+ loadRemote: func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error) {
412+ loadRemoteCalls.Add(1)
413+ return routeStatusSection{}, nil
414+ },
415+ }
416+
417+ sections, err := collectRoutesStatusSections(context.Background(), "", deps)
418+
419+ require.NoError(t, err)
420+ require.Len(t, sections, 1)
421+ assert.Equal(t, "remote", sections[0].Kind)
422+ assert.Equal(t, "missing-remote", sections[0].Name)
423+ require.NotEmpty(t, sections[0].Error)
424+ assert.Contains(t, sections[0].Error, "missing-remote")
425+ assert.Zero(t, loadLocalCalls.Load())
426+ assert.Zero(t, listRemotesCalls.Load())
427+ assert.Zero(t, loadRemoteCalls.Load())
428+ })
429+}
430+
343 func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {431 func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
344 sections := []routeStatusSection{432 sections := []routeStatusSection{
345 {433 {

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