detached d031a8e

fix(broker): keep list and host reads truthful without spawning

commit
d031a8e3d14fd986d8c67fafbd169a290a663034
author
brice · · 2026-09-23 09:28 UTC
parent
b71082c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +281 -16
M internal/adapters/brokerstore/store.go +17 -0
@@ -117,6 +117,23 @@ type Store struct {
117117
118118 var _ ports.BrokerHostStore = (*Store)(nil)
119119
120+// ReadHosts reads committed membership without taking the writer lock or
121+// creating any files. Advisory snapshots are deliberately ignored.
122+func ReadHosts(dir string) (ports.BrokerHosts, error) {
123+ raw, err := readBounded(filepath.Join(dir, "state.json"))
124+ if err != nil {
125+ return ports.BrokerHosts{}, err
126+ }
127+ var st state
128+ if err := strict(raw, &st); err != nil {
129+ return ports.BrokerHosts{}, invalid("state.json: %v", err)
130+ }
131+ if err := validate(st); err != nil {
132+ return ports.BrokerHosts{}, err
133+ }
134+ return ports.BrokerHosts{Revision: st.Hosts.Revision, Hosts: ports.CloneBrokerHostRecords(st.Hosts.Hosts)}, nil
135+}
136+
120137 // Open returns only after validated unified state is durable. The
121138 // recovery record is written first and retained forever as the rollback copy;
122139 // restart reuses its generated identities instead of migrating a second time.
@@ -117,6 +117,23 @@ type Store struct {
117 117
118 var _ ports.BrokerHostStore = (*Store)(nil)118 var _ ports.BrokerHostStore = (*Store)(nil)
119 119
120+// ReadHosts reads committed membership without taking the writer lock or
121+// creating any files. Advisory snapshots are deliberately ignored.
122+func ReadHosts(dir string) (ports.BrokerHosts, error) {
123+ raw, err := readBounded(filepath.Join(dir, "state.json"))
124+ if err != nil {
125+ return ports.BrokerHosts{}, err
126+ }
127+ var st state
128+ if err := strict(raw, &st); err != nil {
129+ return ports.BrokerHosts{}, invalid("state.json: %v", err)
130+ }
131+ if err := validate(st); err != nil {
132+ return ports.BrokerHosts{}, err
133+ }
134+ return ports.BrokerHosts{Revision: st.Hosts.Revision, Hosts: ports.CloneBrokerHostRecords(st.Hosts.Hosts)}, nil
135+}
136+
120 // Open returns only after validated unified state is durable. The137 // Open returns only after validated unified state is durable. The
121 // recovery record is written first and retained forever as the rollback copy;138 // recovery record is written first and retained forever as the rollback copy;
122 // restart reuses its generated identities instead of migrating a second time.139 // restart reuses its generated identities instead of migrating a second time.
M internal/app/broker_daemon_start.go +3 -0
@@ -156,6 +156,9 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports
156156 if target.StartMode == ports.BrokerDaemonExistingOnly {
157157 // An existing-only acquisition is one dial: no lifecycle probe, no spawn
158158 // lock, no process, and no fallback of any kind.
159+ if daemonCarriageAbsence(err) {
160+ return nil, ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
161+ }
159162 return nil, err
160163 }
161164 // A refusal that spawning could never repair is reported unchanged: a
@@ -156,6 +156,9 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports
156 if target.StartMode == ports.BrokerDaemonExistingOnly {156 if target.StartMode == ports.BrokerDaemonExistingOnly {
157 // An existing-only acquisition is one dial: no lifecycle probe, no spawn157 // An existing-only acquisition is one dial: no lifecycle probe, no spawn
158 // lock, no process, and no fallback of any kind.158 // lock, no process, and no fallback of any kind.
159+ if daemonCarriageAbsence(err) {
160+ return nil, ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
161+ }
159 return nil, err162 return nil, err
160 }163 }
161 // A refusal that spawning could never repair is reported unchanged: a164 // A refusal that spawning could never repair is reported unchanged: a
M internal/app/broker_daemon_start_test.go +3 -0
@@ -250,6 +250,9 @@ func TestDaemonStartExistingOnlyNeverSpawns(t *testing.T) {
250250 target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)
251251 raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
252252 require.ErrorIs(t, err, os.ErrNotExist)
253+ var brokerErr ports.BrokerError
254+ require.ErrorAs(t, err, &brokerErr)
255+ require.Equal(t, ports.BrokerErrorNoDaemon, brokerErr.Code)
253256 require.Nil(t, raw)
254257
255258 dials, spawns := scripted.counters()
@@ -250,6 +250,9 @@ func TestDaemonStartExistingOnlyNeverSpawns(t *testing.T) {
250 target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)250 target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)
251 raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)251 raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
252 require.ErrorIs(t, err, os.ErrNotExist)252 require.ErrorIs(t, err, os.ErrNotExist)
253+ var brokerErr ports.BrokerError
254+ require.ErrorAs(t, err, &brokerErr)
255+ require.Equal(t, ports.BrokerErrorNoDaemon, brokerErr.Code)
253 require.Nil(t, raw)256 require.Nil(t, raw)
254 257
255 dials, spawns := scripted.counters()258 dials, spawns := scripted.counters()
A internal/app/list_readonly_test.go +109 -0
@@ -0,0 +1,109 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
17+)
18+
19+func TestObservedHostStatus(t *testing.T) {
20+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
21+ for _, tc := range []struct {
22+ name string
23+ availability domain.RemoteAvailability
24+ age time.Duration
25+ want string
26+ show bool
27+ }{
28+ {"fresh", domain.RemoteAvailabilityReachable, time.Second, "reachable", true},
29+ {"stale", domain.RemoteAvailabilityReachable, 2 * time.Minute, "stale (2m0s since last observation)", false},
30+ {"unknown success", domain.RemoteAvailabilityReachable, 0, "stale (last observation unknown)", false},
31+ {"never observed", domain.RemoteAvailabilityUnknown, time.Second, "not yet observed", false},
32+ {"down", domain.RemoteAvailabilityUnreachable, time.Second, "down (unreachable)", false},
33+ {"auth", domain.RemoteAvailabilityAuthFailed, time.Second, "down (authentication_failed)", false},
34+ {"invalid", domain.RemoteAvailabilityInvalidResponse, time.Second, "down (invalid_response)", false},
35+ {"incompatible", domain.RemoteAvailabilityIncompatible, time.Second, "incompatible (identity changed? use vev host rm/add)", false},
36+ {"no daemon", domain.RemoteAvailabilityNoDaemon, time.Second, "no vev daemon — Enter in the picker to create a session", false},
37+ } {
38+ t.Run(tc.name, func(t *testing.T) {
39+ observed := ports.BrokerDaemonObservation{Availability: tc.availability}
40+ if tc.age != 0 {
41+ observed.LastSuccess = now.Add(-tc.age)
42+ }
43+ status, show := observedHostStatus(observed, now)
44+ require.Equal(t, tc.want, status)
45+ require.Equal(t, tc.show, show)
46+ })
47+ }
48+}
49+
50+func TestSnapshotListNeverShowsCachedUpWhenHostIsNotFresh(t *testing.T) {
51+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
52+ for _, availability := range []domain.RemoteAvailability{domain.RemoteAvailabilityReachable, domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityNoDaemon} {
53+ t.Run(availability.String(), func(t *testing.T) {
54+ remote := ports.BrokerDaemonObservation{
55+ Endpoint: "user@example.test", DisplayOrigin: "user@example.test",
56+ Registration: domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1},
57+ Policy: seamBrokerTestPolicy(), Availability: availability,
58+ LastSuccess: now.Add(-time.Minute), InventoryKnown: true,
59+ Sessions: []catalogue.RemoteCatalogSession{{LifecycleID: domain.SessionLifecycleID{1}, Name: "old", State: catalogue.RemoteCatalogSessionUp}},
60+ }
61+ snapshot := ports.BrokerSnapshot{Epoch: seamBrokerTestEpoch, Revision: 1, Daemons: []ports.BrokerDaemonObservation{remote}}
62+ var output bytes.Buffer
63+ require.NoError(t, renderBrokerSnapshotList(command{listAll: true}, snapshot, &output, now))
64+ require.NotContains(t, output.String(), "old@")
65+ require.NotContains(t, output.String(), " up")
66+ })
67+ }
68+}
69+
70+func TestRunListWithoutBrokerDoesNotStartEitherProcess(t *testing.T) {
71+ previous := connectListBroker
72+ connectListBroker = func(context.Context) (ports.BrokerService, error) { return nil, errBrokerAbsent }
73+ t.Cleanup(func() { connectListBroker = previous })
74+ root := t.TempDir()
75+ t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
76+ t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state"))
77+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
78+ for _, tc := range []struct {
79+ name string
80+ cmd command
81+ want string
82+ }{
83+ {"local", command{kind: kindList}, "no sessions"},
84+ {"remote", command{kind: kindList, listHost: "user@example.test"}, "not connected (no broker running)"},
85+ {"all", command{kind: kindList, listAll: true}, "no hosts"},
86+ } {
87+ t.Run(tc.name, func(t *testing.T) {
88+ output := captureStdout(t, func() { require.NoError(t, runList(context.Background(), tc.cmd)) })
89+ require.Contains(t, output, tc.want)
90+ })
91+ }
92+ _, err := os.Stat(filepath.Join(root, "runtime", "vev"))
93+ require.True(t, errors.Is(err, os.ErrNotExist))
94+}
95+
96+func TestHostListUsesExistingConnector(t *testing.T) {
97+ var output bytes.Buffer
98+ called := false
99+ deps := remoteHostDeps{
100+ connect: func(context.Context) (ports.BrokerService, error) { t.Fatal("list spawned a broker"); return nil, nil },
101+ connectExisting: func(context.Context) (ports.BrokerService, error) { called = true; return nil, errBrokerAbsent },
102+ stdout: &output,
103+ }
104+ t.Setenv("XDG_STATE_HOME", t.TempDir())
105+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
106+ require.NoError(t, runHostCommand(context.Background(), command{hostAction: hostActionList}, deps))
107+ require.True(t, called)
108+ require.Equal(t, "no hosts\n", output.String())
109+}
@@ -0,0 +1,109 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
17+)
18+
19+func TestObservedHostStatus(t *testing.T) {
20+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
21+ for _, tc := range []struct {
22+ name string
23+ availability domain.RemoteAvailability
24+ age time.Duration
25+ want string
26+ show bool
27+ }{
28+ {"fresh", domain.RemoteAvailabilityReachable, time.Second, "reachable", true},
29+ {"stale", domain.RemoteAvailabilityReachable, 2 * time.Minute, "stale (2m0s since last observation)", false},
30+ {"unknown success", domain.RemoteAvailabilityReachable, 0, "stale (last observation unknown)", false},
31+ {"never observed", domain.RemoteAvailabilityUnknown, time.Second, "not yet observed", false},
32+ {"down", domain.RemoteAvailabilityUnreachable, time.Second, "down (unreachable)", false},
33+ {"auth", domain.RemoteAvailabilityAuthFailed, time.Second, "down (authentication_failed)", false},
34+ {"invalid", domain.RemoteAvailabilityInvalidResponse, time.Second, "down (invalid_response)", false},
35+ {"incompatible", domain.RemoteAvailabilityIncompatible, time.Second, "incompatible (identity changed? use vev host rm/add)", false},
36+ {"no daemon", domain.RemoteAvailabilityNoDaemon, time.Second, "no vev daemon — Enter in the picker to create a session", false},
37+ } {
38+ t.Run(tc.name, func(t *testing.T) {
39+ observed := ports.BrokerDaemonObservation{Availability: tc.availability}
40+ if tc.age != 0 {
41+ observed.LastSuccess = now.Add(-tc.age)
42+ }
43+ status, show := observedHostStatus(observed, now)
44+ require.Equal(t, tc.want, status)
45+ require.Equal(t, tc.show, show)
46+ })
47+ }
48+}
49+
50+func TestSnapshotListNeverShowsCachedUpWhenHostIsNotFresh(t *testing.T) {
51+ now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
52+ for _, availability := range []domain.RemoteAvailability{domain.RemoteAvailabilityReachable, domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityNoDaemon} {
53+ t.Run(availability.String(), func(t *testing.T) {
54+ remote := ports.BrokerDaemonObservation{
55+ Endpoint: "user@example.test", DisplayOrigin: "user@example.test",
56+ Registration: domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1},
57+ Policy: seamBrokerTestPolicy(), Availability: availability,
58+ LastSuccess: now.Add(-time.Minute), InventoryKnown: true,
59+ Sessions: []catalogue.RemoteCatalogSession{{LifecycleID: domain.SessionLifecycleID{1}, Name: "old", State: catalogue.RemoteCatalogSessionUp}},
60+ }
61+ snapshot := ports.BrokerSnapshot{Epoch: seamBrokerTestEpoch, Revision: 1, Daemons: []ports.BrokerDaemonObservation{remote}}
62+ var output bytes.Buffer
63+ require.NoError(t, renderBrokerSnapshotList(command{listAll: true}, snapshot, &output, now))
64+ require.NotContains(t, output.String(), "old@")
65+ require.NotContains(t, output.String(), " up")
66+ })
67+ }
68+}
69+
70+func TestRunListWithoutBrokerDoesNotStartEitherProcess(t *testing.T) {
71+ previous := connectListBroker
72+ connectListBroker = func(context.Context) (ports.BrokerService, error) { return nil, errBrokerAbsent }
73+ t.Cleanup(func() { connectListBroker = previous })
74+ root := t.TempDir()
75+ t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
76+ t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state"))
77+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
78+ for _, tc := range []struct {
79+ name string
80+ cmd command
81+ want string
82+ }{
83+ {"local", command{kind: kindList}, "no sessions"},
84+ {"remote", command{kind: kindList, listHost: "user@example.test"}, "not connected (no broker running)"},
85+ {"all", command{kind: kindList, listAll: true}, "no hosts"},
86+ } {
87+ t.Run(tc.name, func(t *testing.T) {
88+ output := captureStdout(t, func() { require.NoError(t, runList(context.Background(), tc.cmd)) })
89+ require.Contains(t, output, tc.want)
90+ })
91+ }
92+ _, err := os.Stat(filepath.Join(root, "runtime", "vev"))
93+ require.True(t, errors.Is(err, os.ErrNotExist))
94+}
95+
96+func TestHostListUsesExistingConnector(t *testing.T) {
97+ var output bytes.Buffer
98+ called := false
99+ deps := remoteHostDeps{
100+ connect: func(context.Context) (ports.BrokerService, error) { t.Fatal("list spawned a broker"); return nil, nil },
101+ connectExisting: func(context.Context) (ports.BrokerService, error) { called = true; return nil, errBrokerAbsent },
102+ stdout: &output,
103+ }
104+ t.Setenv("XDG_STATE_HOME", t.TempDir())
105+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
106+ require.NoError(t, runHostCommand(context.Background(), command{hostAction: hostActionList}, deps))
107+ require.True(t, called)
108+ require.Equal(t, "no hosts\n", output.String())
109+}
M internal/app/remote_hosts.go +94 -9
@@ -2,12 +2,16 @@ package app
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
78 "os"
89 "sort"
910 "text/tabwriter"
11+ "time"
1012
13+ "github.com/bnema/vev/internal/adapters/brokerconfig"
14+ "github.com/bnema/vev/internal/adapters/brokerstore"
1115 "github.com/bnema/vev/internal/domain"
1216 "github.com/bnema/vev/internal/ports"
1317 "github.com/bnema/vev/internal/protocol"
@@ -21,18 +25,22 @@ const (
2125 )
2226
2327 type remoteHostDeps struct {
24- connect func(context.Context) (ports.BrokerService, error)
25- stdout io.Writer
28+ connect func(context.Context) (ports.BrokerService, error)
29+ connectExisting func(context.Context) (ports.BrokerService, error)
30+ stdout io.Writer
2631 }
2732
2833 func defaultRemoteHostDeps() remoteHostDeps {
29- return remoteHostDeps{connect: connectProductionBroker, stdout: os.Stdout}
34+ return remoteHostDeps{connect: connectProductionBroker, connectExisting: connectExistingBroker, stdout: os.Stdout}
3035 }
3136
3237 func (d remoteHostDeps) withDefaults() remoteHostDeps {
3338 if d.connect == nil {
3439 d.connect = connectProductionBroker
3540 }
41+ if d.connectExisting == nil {
42+ d.connectExisting = connectExistingBroker
43+ }
3644 if d.stdout == nil {
3745 d.stdout = os.Stdout
3846 }
@@ -41,8 +49,15 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps {
4149
4250 func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
4351 deps = deps.withDefaults()
44- service, err := deps.connect(ctx)
52+ connect := deps.connect
53+ if cmd.hostAction == hostActionList {
54+ connect = deps.connectExisting
55+ }
56+ service, err := connect(ctx)
4557 if err != nil {
58+ if cmd.hostAction == hostActionList && errors.Is(err, errBrokerAbsent) {
59+ return listDisconnectedHosts(deps.stdout)
60+ }
4661 return err
4762 }
4863 defer func() { _ = service.Close() }()
@@ -86,6 +101,42 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
86101 }
87102 }
88103
104+// listDisconnectedHosts reads configured endpoints without dialing any remote.
105+func listDisconnectedHosts(w io.Writer) error {
106+ state := productionBrokerLayout().State
107+ hosts, err := brokerstore.ReadHosts(state)
108+ var endpoints []string
109+ if errors.Is(err, os.ErrNotExist) {
110+ // Before the first broker run, the config is the only membership.
111+ config, configErr := brokerconfig.LoadPath(productionBrokerLayout(), productionBrokerConfigPath())
112+ if errors.Is(configErr, os.ErrNotExist) {
113+ _, err = fmt.Fprintln(w, "no hosts")
114+ return err
115+ }
116+ if configErr != nil {
117+ return configErr
118+ }
119+ endpoints = config.Endpoints()
120+ } else if err != nil {
121+ return err
122+ } else {
123+ for _, host := range hosts.Hosts {
124+ endpoints = append(endpoints, host.Registration.Endpoint)
125+ }
126+ sort.Strings(endpoints)
127+ }
128+ if len(endpoints) == 0 {
129+ _, err = fmt.Fprintln(w, "no hosts")
130+ return err
131+ }
132+ for _, endpoint := range endpoints {
133+ if _, err := fmt.Fprintf(w, "%s: not connected (no broker running)\n", endpoint); err != nil {
134+ return err
135+ }
136+ }
137+ return nil
138+}
139+
89140 func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
90141 if err := snapshot.Validate(); err != nil {
91142 return fmt.Errorf("vev: reading broker catalogue: %w", err)
@@ -104,10 +155,10 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
104155 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
105156 _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE\tSTATUS")
106157 for _, endpoint := range endpoints {
107- status := "configured"
158+ status := "not connected"
108159 for _, daemon := range snapshot.Daemons {
109- if !daemon.Local && daemon.Endpoint == endpoint && daemon.Availability == domain.RemoteAvailabilityNoDaemon {
110- status = "no daemon"
160+ if !daemon.Local && daemon.Endpoint == endpoint {
161+ status, _ = observedHostStatus(daemon, time.Now())
111162 break
112163 }
113164 }
@@ -117,6 +168,10 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
117168 }
118169
119170 func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer) error {
171+ return renderBrokerSnapshotList(cmd, snapshot, stdout, time.Now())
172+}
173+
174+func renderBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer, now time.Time) error {
120175 if err := snapshot.Validate(); err != nil {
121176 return fmt.Errorf("vev: reading broker catalogue: %w", err)
122177 }
@@ -128,8 +183,15 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
128183 continue
129184 }
130185 found = true
131- if !daemon.Local && daemon.Availability == domain.RemoteAvailabilityNoDaemon {
132- statuses = append(statuses, fmt.Sprintf("%s: no vev daemon — Enter in the picker to create a session", daemon.Endpoint))
186+ status, showSessions := observedHostStatus(daemon, now)
187+ if daemon.Local {
188+ showSessions = daemon.Availability == domain.RemoteAvailabilityReachable
189+ }
190+ if !daemon.Local && status != "reachable" {
191+ statuses = append(statuses, fmt.Sprintf("%s: %s", daemon.Endpoint, status))
192+ }
193+ if !showSessions {
194+ continue
133195 }
134196 for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) {
135197 if daemon.Local {
@@ -153,6 +215,29 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
153215 return nil
154216 }
155217
218+// observedHostStatus never treats cached inventory as proof of current reachability.
219+func observedHostStatus(daemon ports.BrokerDaemonObservation, now time.Time) (string, bool) {
220+ if daemon.Availability == domain.RemoteAvailabilityNoDaemon {
221+ return "no vev daemon — Enter in the picker to create a session", false
222+ }
223+ if daemon.Availability == domain.RemoteAvailabilityUnknown {
224+ return "not yet observed", false
225+ }
226+ if daemon.Availability == domain.RemoteAvailabilityIncompatible {
227+ return "incompatible (identity changed? use vev host rm/add)", false
228+ }
229+ if daemon.Availability != domain.RemoteAvailabilityReachable {
230+ return "down (" + daemon.Availability.String() + ")", false
231+ }
232+ if daemon.LastSuccess.IsZero() || now.Sub(daemon.LastSuccess) > 15*time.Second {
233+ if daemon.LastSuccess.IsZero() {
234+ return "stale (last observation unknown)", false
235+ }
236+ return fmt.Sprintf("stale (%s since last observation)", now.Sub(daemon.LastSuccess).Round(time.Second)), false
237+ }
238+ return "reachable", true
239+}
240+
156241 func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {
157242 out := make([]protocol.SessionInfo, 0, len(sessions))
158243 for _, session := range sessions {
@@ -2,12 +2,16 @@ package app
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "os"8 "os"
8 "sort"9 "sort"
9 "text/tabwriter"10 "text/tabwriter"
11+ "time"
10 12
13+ "github.com/bnema/vev/internal/adapters/brokerconfig"
14+ "github.com/bnema/vev/internal/adapters/brokerstore"
11 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
@@ -21,18 +25,22 @@ const (
21 )25 )
22 26
23 type remoteHostDeps struct {27 type remoteHostDeps struct {
24- connect func(context.Context) (ports.BrokerService, error)28+ connect func(context.Context) (ports.BrokerService, error)
25- stdout io.Writer29+ connectExisting func(context.Context) (ports.BrokerService, error)
30+ stdout io.Writer
26 }31 }
27 32
28 func defaultRemoteHostDeps() remoteHostDeps {33 func defaultRemoteHostDeps() remoteHostDeps {
29- return remoteHostDeps{connect: connectProductionBroker, stdout: os.Stdout}34+ return remoteHostDeps{connect: connectProductionBroker, connectExisting: connectExistingBroker, stdout: os.Stdout}
30 }35 }
31 36
32 func (d remoteHostDeps) withDefaults() remoteHostDeps {37 func (d remoteHostDeps) withDefaults() remoteHostDeps {
33 if d.connect == nil {38 if d.connect == nil {
34 d.connect = connectProductionBroker39 d.connect = connectProductionBroker
35 }40 }
41+ if d.connectExisting == nil {
42+ d.connectExisting = connectExistingBroker
43+ }
36 if d.stdout == nil {44 if d.stdout == nil {
37 d.stdout = os.Stdout45 d.stdout = os.Stdout
38 }46 }
@@ -41,8 +49,15 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps {
41 49
42 func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {50 func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
43 deps = deps.withDefaults()51 deps = deps.withDefaults()
44- service, err := deps.connect(ctx)52+ connect := deps.connect
53+ if cmd.hostAction == hostActionList {
54+ connect = deps.connectExisting
55+ }
56+ service, err := connect(ctx)
45 if err != nil {57 if err != nil {
58+ if cmd.hostAction == hostActionList && errors.Is(err, errBrokerAbsent) {
59+ return listDisconnectedHosts(deps.stdout)
60+ }
46 return err61 return err
47 }62 }
48 defer func() { _ = service.Close() }()63 defer func() { _ = service.Close() }()
@@ -86,6 +101,42 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
86 }101 }
87 }102 }
88 103
104+// listDisconnectedHosts reads configured endpoints without dialing any remote.
105+func listDisconnectedHosts(w io.Writer) error {
106+ state := productionBrokerLayout().State
107+ hosts, err := brokerstore.ReadHosts(state)
108+ var endpoints []string
109+ if errors.Is(err, os.ErrNotExist) {
110+ // Before the first broker run, the config is the only membership.
111+ config, configErr := brokerconfig.LoadPath(productionBrokerLayout(), productionBrokerConfigPath())
112+ if errors.Is(configErr, os.ErrNotExist) {
113+ _, err = fmt.Fprintln(w, "no hosts")
114+ return err
115+ }
116+ if configErr != nil {
117+ return configErr
118+ }
119+ endpoints = config.Endpoints()
120+ } else if err != nil {
121+ return err
122+ } else {
123+ for _, host := range hosts.Hosts {
124+ endpoints = append(endpoints, host.Registration.Endpoint)
125+ }
126+ sort.Strings(endpoints)
127+ }
128+ if len(endpoints) == 0 {
129+ _, err = fmt.Fprintln(w, "no hosts")
130+ return err
131+ }
132+ for _, endpoint := range endpoints {
133+ if _, err := fmt.Fprintf(w, "%s: not connected (no broker running)\n", endpoint); err != nil {
134+ return err
135+ }
136+ }
137+ return nil
138+}
139+
89 func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {140 func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
90 if err := snapshot.Validate(); err != nil {141 if err := snapshot.Validate(); err != nil {
91 return fmt.Errorf("vev: reading broker catalogue: %w", err)142 return fmt.Errorf("vev: reading broker catalogue: %w", err)
@@ -104,10 +155,10 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
104 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)155 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
105 _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE\tSTATUS")156 _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE\tSTATUS")
106 for _, endpoint := range endpoints {157 for _, endpoint := range endpoints {
107- status := "configured"158+ status := "not connected"
108 for _, daemon := range snapshot.Daemons {159 for _, daemon := range snapshot.Daemons {
109- if !daemon.Local && daemon.Endpoint == endpoint && daemon.Availability == domain.RemoteAvailabilityNoDaemon {160+ if !daemon.Local && daemon.Endpoint == endpoint {
110- status = "no daemon"161+ status, _ = observedHostStatus(daemon, time.Now())
111 break162 break
112 }163 }
113 }164 }
@@ -117,6 +168,10 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
117 }168 }
118 169
119 func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer) error {170 func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer) error {
171+ return renderBrokerSnapshotList(cmd, snapshot, stdout, time.Now())
172+}
173+
174+func renderBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer, now time.Time) error {
120 if err := snapshot.Validate(); err != nil {175 if err := snapshot.Validate(); err != nil {
121 return fmt.Errorf("vev: reading broker catalogue: %w", err)176 return fmt.Errorf("vev: reading broker catalogue: %w", err)
122 }177 }
@@ -128,8 +183,15 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
128 continue183 continue
129 }184 }
130 found = true185 found = true
131- if !daemon.Local && daemon.Availability == domain.RemoteAvailabilityNoDaemon {186+ status, showSessions := observedHostStatus(daemon, now)
132- statuses = append(statuses, fmt.Sprintf("%s: no vev daemon — Enter in the picker to create a session", daemon.Endpoint))187+ if daemon.Local {
188+ showSessions = daemon.Availability == domain.RemoteAvailabilityReachable
189+ }
190+ if !daemon.Local && status != "reachable" {
191+ statuses = append(statuses, fmt.Sprintf("%s: %s", daemon.Endpoint, status))
192+ }
193+ if !showSessions {
194+ continue
133 }195 }
134 for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) {196 for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) {
135 if daemon.Local {197 if daemon.Local {
@@ -153,6 +215,29 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
153 return nil215 return nil
154 }216 }
155 217
218+// observedHostStatus never treats cached inventory as proof of current reachability.
219+func observedHostStatus(daemon ports.BrokerDaemonObservation, now time.Time) (string, bool) {
220+ if daemon.Availability == domain.RemoteAvailabilityNoDaemon {
221+ return "no vev daemon — Enter in the picker to create a session", false
222+ }
223+ if daemon.Availability == domain.RemoteAvailabilityUnknown {
224+ return "not yet observed", false
225+ }
226+ if daemon.Availability == domain.RemoteAvailabilityIncompatible {
227+ return "incompatible (identity changed? use vev host rm/add)", false
228+ }
229+ if daemon.Availability != domain.RemoteAvailabilityReachable {
230+ return "down (" + daemon.Availability.String() + ")", false
231+ }
232+ if daemon.LastSuccess.IsZero() || now.Sub(daemon.LastSuccess) > 15*time.Second {
233+ if daemon.LastSuccess.IsZero() {
234+ return "stale (last observation unknown)", false
235+ }
236+ return fmt.Sprintf("stale (%s since last observation)", now.Sub(daemon.LastSuccess).Round(time.Second)), false
237+ }
238+ return "reachable", true
239+}
240+
156 func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {241 func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {
157 out := make([]protocol.SessionInfo, 0, len(sessions))242 out := make([]protocol.SessionInfo, 0, len(sessions))
158 for _, session := range sessions {243 for _, session := range sessions {
M internal/app/run.go +48 -3
@@ -1303,10 +1303,12 @@ func copyTransport(ctx context.Context, src, dst wire.Transport) error {
13031303 }
13041304 }
13051305
1306-// runList obtains local live data or a multi-host catalogue snapshot through
1307-// the per-user broker. It never dials a daemon directly.
1306+// runList reads existing state without starting either background process.
13081307 func runList(ctx context.Context, cmd command) error {
1309- service, err := connectBroker(ctx)
1308+ service, err := connectListBroker(ctx)
1309+ if errors.Is(err, errBrokerAbsent) {
1310+ return runListWithoutBroker(ctx, cmd, os.Stdout)
1311+ }
13101312 if err != nil {
13111313 return unreachableBrokerError(err)
13121314 }
@@ -1320,12 +1322,55 @@ func runList(ctx context.Context, cmd command) error {
13201322 }
13211323 sessions, err := operations.List(ctx, localBrokerOperationRoute(service.Snapshot()))
13221324 if err != nil {
1325+ var brokerErr ports.BrokerError
1326+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
1327+ printSessions(os.Stdout, nil)
1328+ return nil
1329+ }
13231330 return err
13241331 }
13251332 printSessions(os.Stdout, sessions)
13261333 return nil
13271334 }
13281335
1336+var connectListBroker = connectExistingBroker
1337+var dialListDaemon = ipc.DialContext
1338+
1339+func runListWithoutBroker(ctx context.Context, cmd command, out io.Writer) error {
1340+ if cmd.listHost != "" {
1341+ _, err := fmt.Fprintf(out, "%s: not connected (no broker running)\n", cmd.listHost)
1342+ return err
1343+ }
1344+ transport, err := dialListDaemon(ctx, ipc.SocketDir())
1345+ if err != nil {
1346+ if !backendAbsent(err) {
1347+ return fmt.Errorf("vev: list local sessions: %w", err)
1348+ }
1349+ } else {
1350+ connection := sessionwire.NewClientConnection(transport)
1351+ defer func() { _ = connection.Close() }()
1352+ if err := connection.SendClient(protocol.List{}); err != nil {
1353+ return fmt.Errorf("vev: request local sessions: %w", err)
1354+ }
1355+ reply, err := connection.ReceiveServer()
1356+ if err != nil {
1357+ return fmt.Errorf("vev: read local sessions: %w", err)
1358+ }
1359+ sessions, ok := reply.(protocol.Sessions)
1360+ if !ok {
1361+ return fmt.Errorf("vev: unexpected local list reply %T", reply)
1362+ }
1363+ printSessions(out, sessions.Sessions)
1364+ }
1365+ if err != nil {
1366+ printSessions(out, nil)
1367+ }
1368+ if cmd.listAll {
1369+ return listDisconnectedHosts(out)
1370+ }
1371+ return nil
1372+}
1373+
13291374 // printSessions renders a session table (or a friendly note when empty).
13301375 func printSessions(w io.Writer, sessions []protocol.SessionInfo) {
13311376 if len(sessions) == 0 {
@@ -1303,10 +1303,12 @@ func copyTransport(ctx context.Context, src, dst wire.Transport) error {
1303 }1303 }
1304 }1304 }
1305 1305
1306-// runList obtains local live data or a multi-host catalogue snapshot through1306+// runList reads existing state without starting either background process.
1307-// the per-user broker. It never dials a daemon directly.
1308 func runList(ctx context.Context, cmd command) error {1307 func runList(ctx context.Context, cmd command) error {
1309- service, err := connectBroker(ctx)1308+ service, err := connectListBroker(ctx)
1309+ if errors.Is(err, errBrokerAbsent) {
1310+ return runListWithoutBroker(ctx, cmd, os.Stdout)
1311+ }
1310 if err != nil {1312 if err != nil {
1311 return unreachableBrokerError(err)1313 return unreachableBrokerError(err)
1312 }1314 }
@@ -1320,12 +1322,55 @@ func runList(ctx context.Context, cmd command) error {
1320 }1322 }
1321 sessions, err := operations.List(ctx, localBrokerOperationRoute(service.Snapshot()))1323 sessions, err := operations.List(ctx, localBrokerOperationRoute(service.Snapshot()))
1322 if err != nil {1324 if err != nil {
1325+ var brokerErr ports.BrokerError
1326+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
1327+ printSessions(os.Stdout, nil)
1328+ return nil
1329+ }
1323 return err1330 return err
1324 }1331 }
1325 printSessions(os.Stdout, sessions)1332 printSessions(os.Stdout, sessions)
1326 return nil1333 return nil
1327 }1334 }
1328 1335
1336+var connectListBroker = connectExistingBroker
1337+var dialListDaemon = ipc.DialContext
1338+
1339+func runListWithoutBroker(ctx context.Context, cmd command, out io.Writer) error {
1340+ if cmd.listHost != "" {
1341+ _, err := fmt.Fprintf(out, "%s: not connected (no broker running)\n", cmd.listHost)
1342+ return err
1343+ }
1344+ transport, err := dialListDaemon(ctx, ipc.SocketDir())
1345+ if err != nil {
1346+ if !backendAbsent(err) {
1347+ return fmt.Errorf("vev: list local sessions: %w", err)
1348+ }
1349+ } else {
1350+ connection := sessionwire.NewClientConnection(transport)
1351+ defer func() { _ = connection.Close() }()
1352+ if err := connection.SendClient(protocol.List{}); err != nil {
1353+ return fmt.Errorf("vev: request local sessions: %w", err)
1354+ }
1355+ reply, err := connection.ReceiveServer()
1356+ if err != nil {
1357+ return fmt.Errorf("vev: read local sessions: %w", err)
1358+ }
1359+ sessions, ok := reply.(protocol.Sessions)
1360+ if !ok {
1361+ return fmt.Errorf("vev: unexpected local list reply %T", reply)
1362+ }
1363+ printSessions(out, sessions.Sessions)
1364+ }
1365+ if err != nil {
1366+ printSessions(out, nil)
1367+ }
1368+ if cmd.listAll {
1369+ return listDisconnectedHosts(out)
1370+ }
1371+ return nil
1372+}
1373+
1329 // printSessions renders a session table (or a friendly note when empty).1374 // printSessions renders a session table (or a friendly note when empty).
1330 func printSessions(w io.Writer, sessions []protocol.SessionInfo) {1375 func printSessions(w io.Writer, sessions []protocol.SessionInfo) {
1331 if len(sessions) == 0 {1376 if len(sessions) == 0 {
M internal/app/run_broker_test.go +5 -2
@@ -205,11 +205,14 @@ var _ ports.BrokerService = (*seamBrokerService)(nil)
205205 func withSeamBroker(t *testing.T, service ports.BrokerService) {
206206 t.Helper()
207207 previous := connectBroker
208+ previousList := connectListBroker
208209 previousStop := connectDaemonStopBroker
209210 connectBroker = func(context.Context) (ports.BrokerService, error) { return service, nil }
211+ connectListBroker = connectBroker
210212 connectDaemonStopBroker = connectBroker
211213 t.Cleanup(func() {
212214 connectBroker = previous
215+ connectListBroker = previousList
213216 connectDaemonStopBroker = previousStop
214217 })
215218 }
@@ -276,8 +279,8 @@ func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) {
276279 require.Len(t, opens, 1)
277280 require.Equal(t, ports.BrokerStreamControl, opens[0].Purpose)
278281 require.True(t, opens[0].Local)
279- require.Equal(t, ports.BrokerDaemonStartIfNeeded, opens[0].StartMode,
280- "a local listing may start a stopped daemon to read its records")
282+ require.Equal(t, ports.BrokerDaemonExistingOnly, opens[0].StartMode,
283+ "a listing must not start a stopped daemon")
281284 }
282285
283286 // TestRunKillExplicitOutcomesTable pins the runKill outcome contract: an
@@ -205,11 +205,14 @@ var _ ports.BrokerService = (*seamBrokerService)(nil)
205 func withSeamBroker(t *testing.T, service ports.BrokerService) {205 func withSeamBroker(t *testing.T, service ports.BrokerService) {
206 t.Helper()206 t.Helper()
207 previous := connectBroker207 previous := connectBroker
208+ previousList := connectListBroker
208 previousStop := connectDaemonStopBroker209 previousStop := connectDaemonStopBroker
209 connectBroker = func(context.Context) (ports.BrokerService, error) { return service, nil }210 connectBroker = func(context.Context) (ports.BrokerService, error) { return service, nil }
211+ connectListBroker = connectBroker
210 connectDaemonStopBroker = connectBroker212 connectDaemonStopBroker = connectBroker
211 t.Cleanup(func() {213 t.Cleanup(func() {
212 connectBroker = previous214 connectBroker = previous
215+ connectListBroker = previousList
213 connectDaemonStopBroker = previousStop216 connectDaemonStopBroker = previousStop
214 })217 })
215 }218 }
@@ -276,8 +279,8 @@ func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) {
276 require.Len(t, opens, 1)279 require.Len(t, opens, 1)
277 require.Equal(t, ports.BrokerStreamControl, opens[0].Purpose)280 require.Equal(t, ports.BrokerStreamControl, opens[0].Purpose)
278 require.True(t, opens[0].Local)281 require.True(t, opens[0].Local)
279- require.Equal(t, ports.BrokerDaemonStartIfNeeded, opens[0].StartMode,282+ require.Equal(t, ports.BrokerDaemonExistingOnly, opens[0].StartMode,
280- "a local listing may start a stopped daemon to read its records")283+ "a listing must not start a stopped daemon")
281 }284 }
282 285
283 // TestRunKillExplicitOutcomesTable pins the runKill outcome contract: an286 // TestRunKillExplicitOutcomesTable pins the runKill outcome contract: an
M internal/usecase/client/broker_operations.go +1 -1
@@ -123,7 +123,7 @@ func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*Broke
123123 // unreachable: a refused or lost listing is an ordinary error. A new explicit
124124 // list is always safe, so the implementation never retries on its own.
125125 func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {
126- reply, err := o.exchange(ctx, route, ports.BrokerDaemonStartIfNeeded, protocol.List{})
126+ reply, err := o.exchange(ctx, route, ports.BrokerDaemonExistingOnly, protocol.List{})
127127 if err != nil {
128128 if errors.Is(err, errBrokerOperationLost) {
129129 return nil, fmt.Errorf("vev: reading session list: %w", err)
@@ -123,7 +123,7 @@ func NewBrokerOperations(service ports.BrokerService, clock ports.Clock) (*Broke
123 // unreachable: a refused or lost listing is an ordinary error. A new explicit123 // unreachable: a refused or lost listing is an ordinary error. A new explicit
124 // list is always safe, so the implementation never retries on its own.124 // list is always safe, so the implementation never retries on its own.
125 func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {125 func (o *BrokerOperations) List(ctx context.Context, route BrokerOperationRoute) ([]protocol.SessionInfo, error) {
126- reply, err := o.exchange(ctx, route, ports.BrokerDaemonStartIfNeeded, protocol.List{})126+ reply, err := o.exchange(ctx, route, ports.BrokerDaemonExistingOnly, protocol.List{})
127 if err != nil {127 if err != nil {
128 if errors.Is(err, errBrokerOperationLost) {128 if errors.Is(err, errBrokerOperationLost) {
129 return nil, fmt.Errorf("vev: reading session list: %w", err)129 return nil, fmt.Errorf("vev: reading session list: %w", err)
M internal/usecase/client/broker_operations_test.go +1 -1
@@ -480,7 +480,7 @@ func TestBrokerOperationsListUsesOneControlStream(t *testing.T) {
480480 require.Empty(t, request.Name)
481481 require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
482482 require.Empty(t, request.Env)
483- require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
483+ require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
484484 require.True(t, request.Local)
485485 require.Equal(t, service.ConnectionID(), request.Connection)
486486 require.NotZero(t, request.Stream)
@@ -480,7 +480,7 @@ func TestBrokerOperationsListUsesOneControlStream(t *testing.T) {
480 require.Empty(t, request.Name)480 require.Empty(t, request.Name)
481 require.Equal(t, protocol.ExactSessionTarget{}, request.Target)481 require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
482 require.Empty(t, request.Env)482 require.Empty(t, request.Env)
483- require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)483+ require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
484 require.True(t, request.Local)484 require.True(t, request.Local)
485 require.Equal(t, service.ConnectionID(), request.Connection)485 require.Equal(t, service.ConnectionID(), request.Connection)
486 require.NotZero(t, request.Stream)486 require.NotZero(t, request.Stream)

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 12

Tags 11

↑↓ moveenter openesc close