detached 0a610cc

fix(cli): apply config logging, strict explicit config paths and exit codes

commit
0a610cc414bc87ef59c956c5d2c3fea69a6e56d3
author
brice · · 2026-09-23 10:52 UTC
parent
cf75d01
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +130 -32
M cmd/nefertty/main.go +63 -22
@@ -2,11 +2,13 @@ package main
22
33 import (
44 "context"
5+ "errors"
56 "flag"
67 "fmt"
78 "os"
89 "os/signal"
910 "runtime/debug"
11+ "strings"
1012 "syscall"
1113
1214 "github.com/bnema/nefertty/internal/adapters/config"
@@ -14,24 +16,47 @@ import (
1416 "github.com/bnema/nefertty/internal/logging"
1517 )
1618
17-func main() {
18- if err := run(); err != nil {
19- os.Exit(1)
19+type usageError struct{ error }
20+
21+func main() { os.Exit(runCode()) }
22+func runCode() int {
23+ err := run()
24+ if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) {
25+ return 0
26+ }
27+ var usage usageError
28+ if errors.As(err, &usage) {
29+ return 2
30+ }
31+ return 1
32+}
33+
34+func mergeDebug(configured []string, cli string) string {
35+ if cli == "" {
36+ return strings.Join(configured, ",")
37+ }
38+ if len(configured) == 0 {
39+ return cli
2040 }
41+ return strings.Join(configured, ",") + "," + cli
2142 }
2243
2344 func run() error {
2445 if len(os.Args) > 1 && os.Args[1] == "validate-config" {
2546 if len(os.Args) > 3 {
26- err := fmt.Errorf("usage: validate-config [path]")
47+ err := usageError{fmt.Errorf("usage: validate-config [path]")}
2748 fmt.Fprintln(os.Stderr, err)
2849 return err
2950 }
3051 path := config.DefaultPath()
52+ var err error
3153 if len(os.Args) == 3 {
3254 path = os.Args[2]
55+ _, err = config.Load(path)
56+ } else {
57+ _, err = config.LoadDefault()
3358 }
34- if _, err := config.Load(path); err != nil {
59+ if err != nil {
3560 fmt.Fprintln(os.Stderr, err)
3661 return err
3762 }
@@ -57,44 +82,60 @@ func run() error {
5782 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
5883 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
5984 if err := flags.Parse(os.Args[1:]); err != nil {
60- return err
61- }
62- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
63- defer stop()
64- ctx, closeLog, err := logging.Open(ctx, *debugFlag)
65- if err != nil {
66- fmt.Fprintln(os.Stderr, err)
67- return err
85+ if errors.Is(err, flag.ErrHelp) {
86+ return err
87+ }
88+ return usageError{err}
6889 }
69- defer closeLog()
70- log := logging.For(ctx, "app")
7190 if *backend != "drm" && *backend != "headless" {
72- err := fmt.Errorf("invalid backend %q", *backend)
73- log.Error().Err(err).Msg("invalid options")
91+ err := usageError{fmt.Errorf("invalid backend %q", *backend)}
92+ fmt.Fprintln(os.Stderr, err)
7493 return err
7594 }
7695 if *timeout < 0 {
77- err := fmt.Errorf("negative timeout")
78- log.Error().Err(err).Msg("invalid options")
96+ err := usageError{fmt.Errorf("negative timeout")}
97+ fmt.Fprintln(os.Stderr, err)
7998 return err
8099 }
81100 if flags.NArg() != 0 {
82- err := fmt.Errorf("unexpected arguments: %v", flags.Args())
83- log.Error().Err(err).Msg("invalid options")
101+ err := usageError{fmt.Errorf("unexpected arguments: %v", flags.Args())}
102+ fmt.Fprintln(os.Stderr, err)
84103 return err
85104 }
86105 path := *configFlag
106+ var cfgErr error
107+ var cfg = config.Defaults()
87108 if path == "" {
88109 path = config.DefaultPath()
110+ cfg, cfgErr = config.LoadDefault()
111+ } else {
112+ cfg, cfgErr = config.Load(path)
113+ }
114+ if cfgErr != nil {
115+ fmt.Fprintln(os.Stderr, cfgErr)
116+ return cfgErr
117+ }
118+ selected := mergeDebug(cfg.Log.Debug, *debugFlag)
119+ if _, err := logging.ParseDebug(selected); err != nil {
120+ fmt.Fprintln(os.Stderr, err)
121+ return usageError{err}
89122 }
90- cfg, err := config.Load(path)
123+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
124+ defer stop()
125+ ctx, closeLog, err := logging.Open(ctx, cfg.Log.Level, selected)
91126 if err != nil {
92127 fmt.Fprintln(os.Stderr, err)
93128 return err
94129 }
130+ defer closeLog()
131+ log := logging.For(ctx, "app")
95132 configLog := logging.For(ctx, "config")
96133 configLog.Info().Str("path", path).Msg("loaded config")
97134 if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout}); err != nil {
135+ // SIGINT and SIGTERM cancel the context and are clean exits.
136+ if ctx.Err() != nil && errors.Is(err, context.Canceled) {
137+ return nil
138+ }
98139 log.Error().Err(err).Msg("application failed")
99140 return err
100141 }
@@ -2,11 +2,13 @@ package main
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "flag"6 "flag"
6 "fmt"7 "fmt"
7 "os"8 "os"
8 "os/signal"9 "os/signal"
9 "runtime/debug"10 "runtime/debug"
11+ "strings"
10 "syscall"12 "syscall"
11 13
12 "github.com/bnema/nefertty/internal/adapters/config"14 "github.com/bnema/nefertty/internal/adapters/config"
@@ -14,24 +16,47 @@ import (
14 "github.com/bnema/nefertty/internal/logging"16 "github.com/bnema/nefertty/internal/logging"
15 )17 )
16 18
17-func main() {19+type usageError struct{ error }
18- if err := run(); err != nil {20+
19- os.Exit(1)21+func main() { os.Exit(runCode()) }
22+func runCode() int {
23+ err := run()
24+ if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) {
25+ return 0
26+ }
27+ var usage usageError
28+ if errors.As(err, &usage) {
29+ return 2
30+ }
31+ return 1
32+}
33+
34+func mergeDebug(configured []string, cli string) string {
35+ if cli == "" {
36+ return strings.Join(configured, ",")
37+ }
38+ if len(configured) == 0 {
39+ return cli
20 }40 }
41+ return strings.Join(configured, ",") + "," + cli
21 }42 }
22 43
23 func run() error {44 func run() error {
24 if len(os.Args) > 1 && os.Args[1] == "validate-config" {45 if len(os.Args) > 1 && os.Args[1] == "validate-config" {
25 if len(os.Args) > 3 {46 if len(os.Args) > 3 {
26- err := fmt.Errorf("usage: validate-config [path]")47+ err := usageError{fmt.Errorf("usage: validate-config [path]")}
27 fmt.Fprintln(os.Stderr, err)48 fmt.Fprintln(os.Stderr, err)
28 return err49 return err
29 }50 }
30 path := config.DefaultPath()51 path := config.DefaultPath()
52+ var err error
31 if len(os.Args) == 3 {53 if len(os.Args) == 3 {
32 path = os.Args[2]54 path = os.Args[2]
55+ _, err = config.Load(path)
56+ } else {
57+ _, err = config.LoadDefault()
33 }58 }
34- if _, err := config.Load(path); err != nil {59+ if err != nil {
35 fmt.Fprintln(os.Stderr, err)60 fmt.Fprintln(os.Stderr, err)
36 return err61 return err
37 }62 }
@@ -57,44 +82,60 @@ func run() error {
57 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")82 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
58 configFlag := flags.String("config", "", "config path (empty uses XDG default)")83 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
59 if err := flags.Parse(os.Args[1:]); err != nil {84 if err := flags.Parse(os.Args[1:]); err != nil {
60- return err85+ if errors.Is(err, flag.ErrHelp) {
61- }86+ return err
62- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)87+ }
63- defer stop()88+ return usageError{err}
64- ctx, closeLog, err := logging.Open(ctx, *debugFlag)
65- if err != nil {
66- fmt.Fprintln(os.Stderr, err)
67- return err
68 }89 }
69- defer closeLog()
70- log := logging.For(ctx, "app")
71 if *backend != "drm" && *backend != "headless" {90 if *backend != "drm" && *backend != "headless" {
72- err := fmt.Errorf("invalid backend %q", *backend)91+ err := usageError{fmt.Errorf("invalid backend %q", *backend)}
73- log.Error().Err(err).Msg("invalid options")92+ fmt.Fprintln(os.Stderr, err)
74 return err93 return err
75 }94 }
76 if *timeout < 0 {95 if *timeout < 0 {
77- err := fmt.Errorf("negative timeout")96+ err := usageError{fmt.Errorf("negative timeout")}
78- log.Error().Err(err).Msg("invalid options")97+ fmt.Fprintln(os.Stderr, err)
79 return err98 return err
80 }99 }
81 if flags.NArg() != 0 {100 if flags.NArg() != 0 {
82- err := fmt.Errorf("unexpected arguments: %v", flags.Args())101+ err := usageError{fmt.Errorf("unexpected arguments: %v", flags.Args())}
83- log.Error().Err(err).Msg("invalid options")102+ fmt.Fprintln(os.Stderr, err)
84 return err103 return err
85 }104 }
86 path := *configFlag105 path := *configFlag
106+ var cfgErr error
107+ var cfg = config.Defaults()
87 if path == "" {108 if path == "" {
88 path = config.DefaultPath()109 path = config.DefaultPath()
110+ cfg, cfgErr = config.LoadDefault()
111+ } else {
112+ cfg, cfgErr = config.Load(path)
113+ }
114+ if cfgErr != nil {
115+ fmt.Fprintln(os.Stderr, cfgErr)
116+ return cfgErr
117+ }
118+ selected := mergeDebug(cfg.Log.Debug, *debugFlag)
119+ if _, err := logging.ParseDebug(selected); err != nil {
120+ fmt.Fprintln(os.Stderr, err)
121+ return usageError{err}
89 }122 }
90- cfg, err := config.Load(path)123+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
124+ defer stop()
125+ ctx, closeLog, err := logging.Open(ctx, cfg.Log.Level, selected)
91 if err != nil {126 if err != nil {
92 fmt.Fprintln(os.Stderr, err)127 fmt.Fprintln(os.Stderr, err)
93 return err128 return err
94 }129 }
130+ defer closeLog()
131+ log := logging.For(ctx, "app")
95 configLog := logging.For(ctx, "config")132 configLog := logging.For(ctx, "config")
96 configLog.Info().Str("path", path).Msg("loaded config")133 configLog.Info().Str("path", path).Msg("loaded config")
97 if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout}); err != nil {134 if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout}); err != nil {
135+ // SIGINT and SIGTERM cancel the context and are clean exits.
136+ if ctx.Err() != nil && errors.Is(err, context.Canceled) {
137+ return nil
138+ }
98 log.Error().Err(err).Msg("application failed")139 log.Error().Err(err).Msg("application failed")
99 return err140 return err
100 }141 }
A cmd/nefertty/main_test.go +40 -0
@@ -0,0 +1,40 @@
1+package main
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestMergeDebug(t *testing.T) {
10+ if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" {
11+ t.Fatal(got)
12+ }
13+ if got := mergeDebug(nil, ""); got != "" {
14+ t.Fatal(got)
15+ }
16+}
17+func TestExitCodes(t *testing.T) {
18+ for _, tc := range []struct {
19+ args []string
20+ code int
21+ }{
22+ {[]string{"nefertty", "--help"}, 0},
23+ {[]string{"nefertty", "--backend=invalid"}, 2},
24+ {[]string{"nefertty", "--timeout=-1s"}, 2},
25+ {[]string{"nefertty", "extra"}, 2},
26+ {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1},
27+ {[]string{"nefertty", "validate-config", "/nonexistent/nefertty.toml"}, 1},
28+ } {
29+ old := os.Args
30+ os.Args = tc.args
31+ got := runCode()
32+ os.Args = old
33+ if got != tc.code {
34+ t.Errorf("%v: %d want %d", tc.args, got, tc.code)
35+ }
36+ }
37+ if !strings.Contains(mergeDebug([]string{"core"}, "render"), "render") {
38+ t.Fatal("union")
39+ }
40+}
@@ -0,0 +1,40 @@
1+package main
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestMergeDebug(t *testing.T) {
10+ if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" {
11+ t.Fatal(got)
12+ }
13+ if got := mergeDebug(nil, ""); got != "" {
14+ t.Fatal(got)
15+ }
16+}
17+func TestExitCodes(t *testing.T) {
18+ for _, tc := range []struct {
19+ args []string
20+ code int
21+ }{
22+ {[]string{"nefertty", "--help"}, 0},
23+ {[]string{"nefertty", "--backend=invalid"}, 2},
24+ {[]string{"nefertty", "--timeout=-1s"}, 2},
25+ {[]string{"nefertty", "extra"}, 2},
26+ {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1},
27+ {[]string{"nefertty", "validate-config", "/nonexistent/nefertty.toml"}, 1},
28+ } {
29+ old := os.Args
30+ os.Args = tc.args
31+ got := runCode()
32+ os.Args = old
33+ if got != tc.code {
34+ t.Errorf("%v: %d want %d", tc.args, got, tc.code)
35+ }
36+ }
37+ if !strings.Contains(mergeDebug([]string{"core"}, "render"), "render") {
38+ t.Fatal("union")
39+ }
40+}
M internal/adapters/config/config.go +7 -3
@@ -50,12 +50,16 @@ func Defaults() ports.Config {
5050 c.Log.Debug = []string{}
5151 return c
5252 }
53+func LoadDefault() (ports.Config, error) {
54+ c, err := Load(DefaultPath())
55+ if os.IsNotExist(err) {
56+ return Defaults(), nil
57+ }
58+ return c, err
59+}
5360 func Load(path string) (ports.Config, error) {
5461 c := Defaults()
5562 data, err := os.ReadFile(path)
56- if os.IsNotExist(err) {
57- return c, nil
58- }
5963 if err != nil {
6064 return c, err
6165 }
@@ -50,12 +50,16 @@ func Defaults() ports.Config {
50 c.Log.Debug = []string{}50 c.Log.Debug = []string{}
51 return c51 return c
52 }52 }
53+func LoadDefault() (ports.Config, error) {
54+ c, err := Load(DefaultPath())
55+ if os.IsNotExist(err) {
56+ return Defaults(), nil
57+ }
58+ return c, err
59+}
53 func Load(path string) (ports.Config, error) {60 func Load(path string) (ports.Config, error) {
54 c := Defaults()61 c := Defaults()
55 data, err := os.ReadFile(path)62 data, err := os.ReadFile(path)
56- if os.IsNotExist(err) {
57- return c, nil
58- }
59 if err != nil {63 if err != nil {
60 return c, err64 return c, err
61 }65 }
M internal/adapters/config/config_test.go +6 -1
@@ -17,7 +17,12 @@ func TestDefaultsAndLoad(t *testing.T) {
1717 t.Fatalf("defaults: %+v", d)
1818 }
1919 path := filepath.Join(t.TempDir(), "missing")
20- got, err := Load(path)
20+ _, err := Load(path)
21+ if !os.IsNotExist(err) {
22+ t.Fatalf("explicit missing: %v", err)
23+ }
24+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
25+ got, err := LoadDefault()
2126 if err != nil || !reflect.DeepEqual(got, d) {
2227 t.Fatalf("missing: %v %+v", err, got)
2328 }
@@ -17,7 +17,12 @@ func TestDefaultsAndLoad(t *testing.T) {
17 t.Fatalf("defaults: %+v", d)17 t.Fatalf("defaults: %+v", d)
18 }18 }
19 path := filepath.Join(t.TempDir(), "missing")19 path := filepath.Join(t.TempDir(), "missing")
20- got, err := Load(path)20+ _, err := Load(path)
21+ if !os.IsNotExist(err) {
22+ t.Fatalf("explicit missing: %v", err)
23+ }
24+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
25+ got, err := LoadDefault()
21 if err != nil || !reflect.DeepEqual(got, d) {26 if err != nil || !reflect.DeepEqual(got, d) {
22 t.Fatalf("missing: %v %+v", err, got)27 t.Fatalf("missing: %v %+v", err, got)
23 }28 }
M internal/logging/logging.go +13 -5
@@ -19,6 +19,7 @@ var components = map[string]bool{
1919 }
2020
2121 type debugKey struct{}
22+type levelKey struct{}
2223 type debugSet map[string]bool
2324
2425 func ParseDebug(value string) (map[string]bool, error) {
@@ -41,7 +42,7 @@ func ParseDebug(value string) (map[string]bool, error) {
4142
4243 // Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx.
4344 // The caller must invoke close after all logging is complete.
44-func Open(ctx context.Context, debug string) (context.Context, func() error, error) {
45+func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {
4546 selected, err := ParseDebug(debug)
4647 if err != nil {
4748 return nil, nil, err
@@ -66,12 +67,13 @@ func Open(ctx context.Context, debug string) (context.Context, func() error, err
6667 if err != nil {
6768 return nil, nil, err
6869 }
69- var output = zerowrap.Config{Level: "debug", Format: "json", Output: file}
70+ var output = zerowrap.Config{Level: level, Format: "json", Output: file}
7071 if term.IsTerminal(int(os.Stderr.Fd())) {
7172 output.Output = &consoleAndFile{file: file}
7273 }
7374 ctx = zerowrap.WithCtx(ctx, zerowrap.New(output))
7475 ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))
76+ ctx = context.WithValue(ctx, levelKey{}, level)
7577 return ctx, file.Close, nil
7678 }
7779
@@ -79,9 +81,7 @@ type consoleAndFile struct{ file *os.File }
7981
8082 func (w *consoleAndFile) Write(p []byte) (int, error) {
8183 // JSON remains the canonical output; terminal users also see each entry.
82- if _, err := os.Stderr.Write(p); err != nil {
83- return 0, err
84- }
84+ _, _ = os.Stderr.Write(p)
8585 return w.file.Write(p)
8686 }
8787
@@ -90,6 +90,14 @@ func For(ctx context.Context, component string) zerowrap.Logger {
9090 log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)
9191 selected, _ := ctx.Value(debugKey{}).(debugSet)
9292 level := zerolog.InfoLevel
93+ switch ctx.Value(levelKey{}) {
94+ case "debug":
95+ level = zerolog.DebugLevel
96+ case "warn":
97+ level = zerolog.WarnLevel
98+ case "error":
99+ level = zerolog.ErrorLevel
100+ }
93101 if selected["all"] || selected[component] {
94102 level = zerolog.DebugLevel
95103 }
@@ -19,6 +19,7 @@ var components = map[string]bool{
19 }19 }
20 20
21 type debugKey struct{}21 type debugKey struct{}
22+type levelKey struct{}
22 type debugSet map[string]bool23 type debugSet map[string]bool
23 24
24 func ParseDebug(value string) (map[string]bool, error) {25 func ParseDebug(value string) (map[string]bool, error) {
@@ -41,7 +42,7 @@ func ParseDebug(value string) (map[string]bool, error) {
41 42
42 // Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx.43 // Open rotates the previous run's log, opens a fresh JSON log and attaches the logger to ctx.
43 // The caller must invoke close after all logging is complete.44 // The caller must invoke close after all logging is complete.
44-func Open(ctx context.Context, debug string) (context.Context, func() error, error) {45+func Open(ctx context.Context, level, debug string) (context.Context, func() error, error) {
45 selected, err := ParseDebug(debug)46 selected, err := ParseDebug(debug)
46 if err != nil {47 if err != nil {
47 return nil, nil, err48 return nil, nil, err
@@ -66,12 +67,13 @@ func Open(ctx context.Context, debug string) (context.Context, func() error, err
66 if err != nil {67 if err != nil {
67 return nil, nil, err68 return nil, nil, err
68 }69 }
69- var output = zerowrap.Config{Level: "debug", Format: "json", Output: file}70+ var output = zerowrap.Config{Level: level, Format: "json", Output: file}
70 if term.IsTerminal(int(os.Stderr.Fd())) {71 if term.IsTerminal(int(os.Stderr.Fd())) {
71 output.Output = &consoleAndFile{file: file}72 output.Output = &consoleAndFile{file: file}
72 }73 }
73 ctx = zerowrap.WithCtx(ctx, zerowrap.New(output))74 ctx = zerowrap.WithCtx(ctx, zerowrap.New(output))
74 ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))75 ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))
76+ ctx = context.WithValue(ctx, levelKey{}, level)
75 return ctx, file.Close, nil77 return ctx, file.Close, nil
76 }78 }
77 79
@@ -79,9 +81,7 @@ type consoleAndFile struct{ file *os.File }
79 81
80 func (w *consoleAndFile) Write(p []byte) (int, error) {82 func (w *consoleAndFile) Write(p []byte) (int, error) {
81 // JSON remains the canonical output; terminal users also see each entry.83 // JSON remains the canonical output; terminal users also see each entry.
82- if _, err := os.Stderr.Write(p); err != nil {84+ _, _ = os.Stderr.Write(p)
83- return 0, err
84- }
85 return w.file.Write(p)85 return w.file.Write(p)
86 }86 }
87 87
@@ -90,6 +90,14 @@ func For(ctx context.Context, component string) zerowrap.Logger {
90 log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)90 log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)
91 selected, _ := ctx.Value(debugKey{}).(debugSet)91 selected, _ := ctx.Value(debugKey{}).(debugSet)
92 level := zerolog.InfoLevel92 level := zerolog.InfoLevel
93+ switch ctx.Value(levelKey{}) {
94+ case "debug":
95+ level = zerolog.DebugLevel
96+ case "warn":
97+ level = zerolog.WarnLevel
98+ case "error":
99+ level = zerolog.ErrorLevel
100+ }
93 if selected["all"] || selected[component] {101 if selected["all"] || selected[component] {
94 level = zerolog.DebugLevel102 level = zerolog.DebugLevel
95 }103 }
M internal/logging/logging_test.go +1 -1
@@ -34,7 +34,7 @@ func TestComponentLevelsAndRotation(t *testing.T) {
3434 t.Setenv("XDG_STATE_HOME", t.TempDir())
3535 path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")
3636 for run := 0; run < 2; run++ {
37- ctx, closeLog, err := Open(context.Background(), "core")
37+ ctx, closeLog, err := Open(context.Background(), "info", "core")
3838 if err != nil {
3939 t.Fatal(err)
4040 }
@@ -34,7 +34,7 @@ func TestComponentLevelsAndRotation(t *testing.T) {
34 t.Setenv("XDG_STATE_HOME", t.TempDir())34 t.Setenv("XDG_STATE_HOME", t.TempDir())
35 path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")35 path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")
36 for run := 0; run < 2; run++ {36 for run := 0; run < 2; run++ {
37- ctx, closeLog, err := Open(context.Background(), "core")37+ ctx, closeLog, err := Open(context.Background(), "info", "core")
38 if err != nil {38 if err != nil {
39 t.Fatal(err)39 t.Fatal(err)
40 }40 }

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 23

Tags 10

↑↓ moveenter openesc close