detached 4308b69

chore(repo): bootstrap nefertty skeleton, tooling and logging

commit
4308b69dd41bd5779facd395059e8126fc5374a9
author
brice · · 2026-09-23 10:36 UTC
parent
none (root commit)
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +412 -0
A .github/workflows/ci.yml +15 -0
@@ -0,0 +1,15 @@
1+name: CI
2+on: [push, pull_request]
3+jobs:
4+ check:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: actions/checkout@v4
8+ - uses: actions/setup-go@v5
9+ with:
10+ go-version: '1.27'
11+ - run: go install github.com/bnema/hexcheck@latest
12+ - run: go install github.com/vektra/mockery/v3@v3.7.4
13+ - run: make check
14+ - run: make race
15+ - run: make mocks-check
@@ -0,0 +1,15 @@
1+name: CI
2+on: [push, pull_request]
3+jobs:
4+ check:
5+ runs-on: ubuntu-latest
6+ steps:
7+ - uses: actions/checkout@v4
8+ - uses: actions/setup-go@v5
9+ with:
10+ go-version: '1.27'
11+ - run: go install github.com/bnema/hexcheck@latest
12+ - run: go install github.com/vektra/mockery/v3@v3.7.4
13+ - run: make check
14+ - run: make race
15+ - run: make mocks-check
A .gitignore +5 -0
@@ -0,0 +1,5 @@
1+/nefertty
2+*.test
3+*.out
4+coverage/
5+.worktrees/
@@ -0,0 +1,5 @@
1+/nefertty
2+*.test
3+*.out
4+coverage/
5+.worktrees/
A .hexcheck.yaml +13 -0
@@ -0,0 +1,13 @@
1+version: 1
2+components:
3+ core: { role: core, paths: [internal/core/**] }
4+ ports: { role: ports, paths: [internal/ports/**] }
5+ adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**] }
6+ # app is the composition root: it wires adapters to core, like cmd.
7+ entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] }
8+ generated: { role: ignore, paths: [internal/mocks/**] }
9+rules:
10+ no-adapter-imports-in-core: error
11+ no-infra-imports-in-usecase: error
12+ no-framework-types-in-core: error
13+ no-infra-types-in-ports: error
@@ -0,0 +1,13 @@
1+version: 1
2+components:
3+ core: { role: core, paths: [internal/core/**] }
4+ ports: { role: ports, paths: [internal/ports/**] }
5+ adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**] }
6+ # app is the composition root: it wires adapters to core, like cmd.
7+ entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] }
8+ generated: { role: ignore, paths: [internal/mocks/**] }
9+rules:
10+ no-adapter-imports-in-core: error
11+ no-infra-imports-in-usecase: error
12+ no-framework-types-in-core: error
13+ no-infra-types-in-ports: error
A .mockery.yml +9 -0
@@ -0,0 +1,9 @@
1+# No interfaces exist yet; Mockery v3 treats this package as a clean no-op.
2+all: false
3+packages:
4+ github.com/bnema/nefertty/internal/ports:
5+ config:
6+ all: true
7+ dir: internal/mocks/{{.SrcPackageName}}
8+ pkgname: '{{.SrcPackageName}}'
9+ filename: '{{.InterfaceName}}.go'
@@ -0,0 +1,9 @@
1+# No interfaces exist yet; Mockery v3 treats this package as a clean no-op.
2+all: false
3+packages:
4+ github.com/bnema/nefertty/internal/ports:
5+ config:
6+ all: true
7+ dir: internal/mocks/{{.SrcPackageName}}
8+ pkgname: '{{.SrcPackageName}}'
9+ filename: '{{.InterfaceName}}.go'
A AGENTS.md +9 -0
@@ -0,0 +1,9 @@
1+# Repository rules
2+
3+- Use Go 1.27 only; normal builds and tests use CGO_ENABLED=0.
4+- Keep a lightweight hexagonal architecture: core imports only stdlib and ports.
5+- Owner goroutines own window state; no mutex on window state.
6+- Generate mocks only via Mockery v3 from ports; never handwrite mocks.
7+- Use zerowrap logging with a `component` field on every entry.
8+- Run tests with `-race`.
9+- Use signed conventional commits: `type(scope): description`.
@@ -0,0 +1,9 @@
1+# Repository rules
2+
3+- Use Go 1.27 only; normal builds and tests use CGO_ENABLED=0.
4+- Keep a lightweight hexagonal architecture: core imports only stdlib and ports.
5+- Owner goroutines own window state; no mutex on window state.
6+- Generate mocks only via Mockery v3 from ports; never handwrite mocks.
7+- Use zerowrap logging with a `component` field on every entry.
8+- Run tests with `-race`.
9+- Use signed conventional commits: `type(scope): description`.
A LICENSE +21 -0
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 bnema
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
@@ -0,0 +1,21 @@
1+MIT License
2+
3+Copyright (c) 2026 bnema
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
A Makefile +18 -0
@@ -0,0 +1,18 @@
1+.PHONY: build test vet race mocks mocks-check arch check
2+build:
3+ CGO_ENABLED=0 go build ./...
4+test:
5+ CGO_ENABLED=0 go test ./...
6+vet:
7+ CGO_ENABLED=0 go vet ./...
8+race:
9+ # cgo is enabled only for the race test binary.
10+ CGO_ENABLED=1 go test -race ./...
11+mocks:
12+ $(HOME)/go/bin/mockery
13+mocks-check: mocks
14+ git diff --exit-code -- internal/mocks
15+ @test -z "$$(git ls-files --others --exclude-standard internal/mocks)"
16+arch:
17+ $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
18+check: vet test arch
@@ -0,0 +1,18 @@
1+.PHONY: build test vet race mocks mocks-check arch check
2+build:
3+ CGO_ENABLED=0 go build ./...
4+test:
5+ CGO_ENABLED=0 go test ./...
6+vet:
7+ CGO_ENABLED=0 go vet ./...
8+race:
9+ # cgo is enabled only for the race test binary.
10+ CGO_ENABLED=1 go test -race ./...
11+mocks:
12+ $(HOME)/go/bin/mockery
13+mocks-check: mocks
14+ git diff --exit-code -- internal/mocks
15+ @test -z "$$(git ls-files --others --exclude-standard internal/mocks)"
16+arch:
17+ $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
18+check: vet test arch
A README.md +9 -0
@@ -0,0 +1,9 @@
1+# NeferTTY
2+
3+NeferTTY is a minimal Wayland compositor in Go with Niri-style scrollable columns. This repository currently contains only the skeleton and logging.
4+
5+Requires Linux and Go 1.27.
6+
7+Build: `make build`
8+
9+Run: `nefertty --backend=headless --timeout 5s`
@@ -0,0 +1,9 @@
1+# NeferTTY
2+
3+NeferTTY is a minimal Wayland compositor in Go with Niri-style scrollable columns. This repository currently contains only the skeleton and logging.
4+
5+Requires Linux and Go 1.27.
6+
7+Build: `make build`
8+
9+Run: `nefertty --backend=headless --timeout 5s`
A cmd/nefertty/main.go +73 -0
@@ -0,0 +1,73 @@
1+package main
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+ "os/signal"
9+ "runtime/debug"
10+ "syscall"
11+
12+ "github.com/bnema/nefertty/internal/app"
13+ "github.com/bnema/nefertty/internal/logging"
14+)
15+
16+func main() {
17+ if err := run(); err != nil {
18+ os.Exit(1)
19+ }
20+}
21+
22+func run() error {
23+ if len(os.Args) == 2 && os.Args[1] == "version" {
24+ info, ok := debug.ReadBuildInfo()
25+ if !ok {
26+ fmt.Println("unknown")
27+ return nil
28+ }
29+ version := info.Main.Version
30+ if version == "" {
31+ version = "(devel)"
32+ }
33+ fmt.Println(version)
34+ return nil
35+ }
36+ flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
37+ backend := flags.String("backend", "drm", "drm or headless")
38+ timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
39+ debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
40+ config := flags.String("config", "", "config path (empty uses XDG default later)")
41+ if err := flags.Parse(os.Args[1:]); err != nil {
42+ return err
43+ }
44+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
45+ defer stop()
46+ ctx, closeLog, err := logging.Open(ctx, *debugFlag)
47+ if err != nil {
48+ fmt.Fprintln(os.Stderr, err)
49+ return err
50+ }
51+ defer closeLog()
52+ log := logging.For(ctx, "app")
53+ if *backend != "drm" && *backend != "headless" {
54+ err := fmt.Errorf("invalid backend %q", *backend)
55+ log.Error().Err(err).Msg("invalid options")
56+ return err
57+ }
58+ if *timeout < 0 {
59+ err := fmt.Errorf("negative timeout")
60+ log.Error().Err(err).Msg("invalid options")
61+ return err
62+ }
63+ if flags.NArg() != 0 {
64+ err := fmt.Errorf("unexpected arguments: %v", flags.Args())
65+ log.Error().Err(err).Msg("invalid options")
66+ return err
67+ }
68+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: *config, Timeout: *timeout}); err != nil {
69+ log.Error().Err(err).Msg("application failed")
70+ return err
71+ }
72+ return nil
73+}
@@ -0,0 +1,73 @@
1+package main
2+
3+import (
4+ "context"
5+ "flag"
6+ "fmt"
7+ "os"
8+ "os/signal"
9+ "runtime/debug"
10+ "syscall"
11+
12+ "github.com/bnema/nefertty/internal/app"
13+ "github.com/bnema/nefertty/internal/logging"
14+)
15+
16+func main() {
17+ if err := run(); err != nil {
18+ os.Exit(1)
19+ }
20+}
21+
22+func run() error {
23+ if len(os.Args) == 2 && os.Args[1] == "version" {
24+ info, ok := debug.ReadBuildInfo()
25+ if !ok {
26+ fmt.Println("unknown")
27+ return nil
28+ }
29+ version := info.Main.Version
30+ if version == "" {
31+ version = "(devel)"
32+ }
33+ fmt.Println(version)
34+ return nil
35+ }
36+ flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
37+ backend := flags.String("backend", "drm", "drm or headless")
38+ timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
39+ debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
40+ config := flags.String("config", "", "config path (empty uses XDG default later)")
41+ if err := flags.Parse(os.Args[1:]); err != nil {
42+ return err
43+ }
44+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
45+ defer stop()
46+ ctx, closeLog, err := logging.Open(ctx, *debugFlag)
47+ if err != nil {
48+ fmt.Fprintln(os.Stderr, err)
49+ return err
50+ }
51+ defer closeLog()
52+ log := logging.For(ctx, "app")
53+ if *backend != "drm" && *backend != "headless" {
54+ err := fmt.Errorf("invalid backend %q", *backend)
55+ log.Error().Err(err).Msg("invalid options")
56+ return err
57+ }
58+ if *timeout < 0 {
59+ err := fmt.Errorf("negative timeout")
60+ log.Error().Err(err).Msg("invalid options")
61+ return err
62+ }
63+ if flags.NArg() != 0 {
64+ err := fmt.Errorf("unexpected arguments: %v", flags.Args())
65+ log.Error().Err(err).Msg("invalid options")
66+ return err
67+ }
68+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: *config, Timeout: *timeout}); err != nil {
69+ log.Error().Err(err).Msg("application failed")
70+ return err
71+ }
72+ return nil
73+}
A go.mod +16 -0
@@ -0,0 +1,16 @@
1+module github.com/bnema/nefertty
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/zerowrap v1.4.1
7+ github.com/rs/zerolog v1.35.1
8+ golang.org/x/term v0.46.0
9+)
10+
11+require (
12+ github.com/mattn/go-colorable v0.1.15 // indirect
13+ github.com/mattn/go-isatty v0.0.22 // indirect
14+ golang.org/x/sys v0.48.0 // indirect
15+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
16+)
@@ -0,0 +1,16 @@
1+module github.com/bnema/nefertty
2+
3+go 1.27
4+
5+require (
6+ github.com/bnema/zerowrap v1.4.1
7+ github.com/rs/zerolog v1.35.1
8+ golang.org/x/term v0.46.0
9+)
10+
11+require (
12+ github.com/mattn/go-colorable v0.1.15 // indirect
13+ github.com/mattn/go-isatty v0.0.22 // indirect
14+ golang.org/x/sys v0.48.0 // indirect
15+ gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
16+)
A go.sum +14 -0 generated file: collapsed
A internal/app/app.go +27 -0
@@ -0,0 +1,27 @@
1+// Package app coordinates the application lifecycle.
2+package app
3+
4+import (
5+ "context"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/logging"
9+)
10+
11+type Options struct {
12+ Backend string
13+ Config string
14+ Timeout time.Duration
15+}
16+
17+func Run(ctx context.Context, opts Options) error {
18+ log := logging.For(ctx, "app")
19+ log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
20+ if opts.Timeout > 0 {
21+ var cancel context.CancelFunc
22+ ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
23+ defer cancel()
24+ }
25+ <-ctx.Done()
26+ return nil
27+}
@@ -0,0 +1,27 @@
1+// Package app coordinates the application lifecycle.
2+package app
3+
4+import (
5+ "context"
6+ "time"
7+
8+ "github.com/bnema/nefertty/internal/logging"
9+)
10+
11+type Options struct {
12+ Backend string
13+ Config string
14+ Timeout time.Duration
15+}
16+
17+func Run(ctx context.Context, opts Options) error {
18+ log := logging.For(ctx, "app")
19+ log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
20+ if opts.Timeout > 0 {
21+ var cancel context.CancelFunc
22+ ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
23+ defer cancel()
24+ }
25+ <-ctx.Done()
26+ return nil
27+}
A internal/core/doc.go +3 -0
@@ -0,0 +1,3 @@
1+// Package core holds pure layout, focus, and command domain logic.
2+// It depends only on the standard library and internal/ports.
3+package core
@@ -0,0 +1,3 @@
1+// Package core holds pure layout, focus, and command domain logic.
2+// It depends only on the standard library and internal/ports.
3+package core
A internal/logging/logging.go +98 -0
@@ -0,0 +1,98 @@
1+// Package logging configures component-scoped zerowrap logging.
2+package logging
3+
4+import (
5+ "context"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/rs/zerolog"
13+ "golang.org/x/term"
14+)
15+
16+var components = map[string]bool{
17+ "core": true, "wayland": true, "input": true, "drm": true,
18+ "render": true, "sync": true, "config": true, "app": true,
19+}
20+
21+type debugKey struct{}
22+type debugSet map[string]bool
23+
24+func ParseDebug(value string) (map[string]bool, error) {
25+ selected := make(debugSet)
26+ if value == "" {
27+ return selected, nil
28+ }
29+ for _, name := range strings.Split(value, ",") {
30+ if name == "all" {
31+ selected[name] = true
32+ continue
33+ }
34+ if !components[name] {
35+ return nil, fmt.Errorf("invalid debug component %q", name)
36+ }
37+ selected[name] = true
38+ }
39+ return selected, nil
40+}
41+
42+// 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+func Open(ctx context.Context, debug string) (context.Context, func() error, error) {
45+ selected, err := ParseDebug(debug)
46+ if err != nil {
47+ return nil, nil, err
48+ }
49+ state := os.Getenv("XDG_STATE_HOME")
50+ if state == "" {
51+ home, err := os.UserHomeDir()
52+ if err != nil {
53+ return nil, nil, err
54+ }
55+ state = filepath.Join(home, ".local", "state")
56+ }
57+ dir := filepath.Join(state, "nefertty")
58+ if err := os.MkdirAll(dir, 0o700); err != nil {
59+ return nil, nil, err
60+ }
61+ path := filepath.Join(dir, "nefertty.log")
62+ if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) {
63+ return nil, nil, err
64+ }
65+ file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
66+ if err != nil {
67+ return nil, nil, err
68+ }
69+ var output = zerowrap.Config{Level: "debug", Format: "json", Output: file}
70+ if term.IsTerminal(int(os.Stderr.Fd())) {
71+ output.Output = &consoleAndFile{file: file}
72+ }
73+ ctx = zerowrap.WithCtx(ctx, zerowrap.New(output))
74+ ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))
75+ return ctx, file.Close, nil
76+}
77+
78+type consoleAndFile struct{ file *os.File }
79+
80+func (w *consoleAndFile) Write(p []byte) (int, error) {
81+ // 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+ }
85+ return w.file.Write(p)
86+}
87+
88+// For returns a component logger with debug enabled only for selected components.
89+func For(ctx context.Context, component string) zerowrap.Logger {
90+ log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)
91+ selected, _ := ctx.Value(debugKey{}).(debugSet)
92+ level := zerolog.InfoLevel
93+ if selected["all"] || selected[component] {
94+ level = zerolog.DebugLevel
95+ }
96+ log = zerowrap.Logger{Logger: log.Level(level)}
97+ return log
98+}
@@ -0,0 +1,98 @@
1+// Package logging configures component-scoped zerowrap logging.
2+package logging
3+
4+import (
5+ "context"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/rs/zerolog"
13+ "golang.org/x/term"
14+)
15+
16+var components = map[string]bool{
17+ "core": true, "wayland": true, "input": true, "drm": true,
18+ "render": true, "sync": true, "config": true, "app": true,
19+}
20+
21+type debugKey struct{}
22+type debugSet map[string]bool
23+
24+func ParseDebug(value string) (map[string]bool, error) {
25+ selected := make(debugSet)
26+ if value == "" {
27+ return selected, nil
28+ }
29+ for _, name := range strings.Split(value, ",") {
30+ if name == "all" {
31+ selected[name] = true
32+ continue
33+ }
34+ if !components[name] {
35+ return nil, fmt.Errorf("invalid debug component %q", name)
36+ }
37+ selected[name] = true
38+ }
39+ return selected, nil
40+}
41+
42+// 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+func Open(ctx context.Context, debug string) (context.Context, func() error, error) {
45+ selected, err := ParseDebug(debug)
46+ if err != nil {
47+ return nil, nil, err
48+ }
49+ state := os.Getenv("XDG_STATE_HOME")
50+ if state == "" {
51+ home, err := os.UserHomeDir()
52+ if err != nil {
53+ return nil, nil, err
54+ }
55+ state = filepath.Join(home, ".local", "state")
56+ }
57+ dir := filepath.Join(state, "nefertty")
58+ if err := os.MkdirAll(dir, 0o700); err != nil {
59+ return nil, nil, err
60+ }
61+ path := filepath.Join(dir, "nefertty.log")
62+ if err := os.Rename(path, path+".1"); err != nil && !os.IsNotExist(err) {
63+ return nil, nil, err
64+ }
65+ file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
66+ if err != nil {
67+ return nil, nil, err
68+ }
69+ var output = zerowrap.Config{Level: "debug", Format: "json", Output: file}
70+ if term.IsTerminal(int(os.Stderr.Fd())) {
71+ output.Output = &consoleAndFile{file: file}
72+ }
73+ ctx = zerowrap.WithCtx(ctx, zerowrap.New(output))
74+ ctx = context.WithValue(ctx, debugKey{}, debugSet(selected))
75+ return ctx, file.Close, nil
76+}
77+
78+type consoleAndFile struct{ file *os.File }
79+
80+func (w *consoleAndFile) Write(p []byte) (int, error) {
81+ // 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+ }
85+ return w.file.Write(p)
86+}
87+
88+// For returns a component logger with debug enabled only for selected components.
89+func For(ctx context.Context, component string) zerowrap.Logger {
90+ log := zerowrap.FromCtxWithField(ctx, zerowrap.FieldComponent, component)
91+ selected, _ := ctx.Value(debugKey{}).(debugSet)
92+ level := zerolog.InfoLevel
93+ if selected["all"] || selected[component] {
94+ level = zerolog.DebugLevel
95+ }
96+ log = zerowrap.Logger{Logger: log.Level(level)}
97+ return log
98+}
A internal/logging/logging_test.go +80 -0
@@ -0,0 +1,80 @@
1+package logging
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestParseDebug(t *testing.T) {
13+ for _, tc := range []struct {
14+ input string
15+ wantError bool
16+ selected []string
17+ }{
18+ {"", false, nil}, {"core,app", false, []string{"core", "app"}},
19+ {"all", false, []string{"all"}}, {"unknown", true, nil}, {"core,", true, nil},
20+ } {
21+ got, err := ParseDebug(tc.input)
22+ if (err != nil) != tc.wantError {
23+ t.Fatalf("ParseDebug(%q): %v", tc.input, err)
24+ }
25+ for _, name := range tc.selected {
26+ if !got[name] {
27+ t.Errorf("ParseDebug(%q) missing %s", tc.input, name)
28+ }
29+ }
30+ }
31+}
32+
33+func TestComponentLevelsAndRotation(t *testing.T) {
34+ t.Setenv("XDG_STATE_HOME", t.TempDir())
35+ path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")
36+ for run := 0; run < 2; run++ {
37+ ctx, closeLog, err := Open(context.Background(), "core")
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ core := For(ctx, "core")
42+ app := For(ctx, "app")
43+ core.Debug().Msg("visible")
44+ app.Debug().Msg("hidden")
45+ app.Info().Msg("startup")
46+ if err := closeLog(); err != nil {
47+ t.Fatal(err)
48+ }
49+ data, err := os.ReadFile(path)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ lines := splitLines(data)
54+ if len(lines) != 2 {
55+ t.Fatalf("expected 2 entries, got %q", data)
56+ }
57+ for _, line := range lines {
58+ var entry map[string]any
59+ if err := json.Unmarshal(line, &entry); err != nil {
60+ t.Fatal(err)
61+ }
62+ if entry["component"] != "core" && entry["component"] != "app" {
63+ t.Fatalf("missing component: %v", entry)
64+ }
65+ }
66+ }
67+ if _, err := os.Stat(path + ".1"); err != nil {
68+ t.Fatal(err)
69+ }
70+}
71+
72+func splitLines(data []byte) [][]byte {
73+ var lines [][]byte
74+ for _, line := range bytes.Split(data, []byte("\n")) {
75+ if len(line) > 0 {
76+ lines = append(lines, line)
77+ }
78+ }
79+ return lines
80+}
@@ -0,0 +1,80 @@
1+package logging
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+)
11+
12+func TestParseDebug(t *testing.T) {
13+ for _, tc := range []struct {
14+ input string
15+ wantError bool
16+ selected []string
17+ }{
18+ {"", false, nil}, {"core,app", false, []string{"core", "app"}},
19+ {"all", false, []string{"all"}}, {"unknown", true, nil}, {"core,", true, nil},
20+ } {
21+ got, err := ParseDebug(tc.input)
22+ if (err != nil) != tc.wantError {
23+ t.Fatalf("ParseDebug(%q): %v", tc.input, err)
24+ }
25+ for _, name := range tc.selected {
26+ if !got[name] {
27+ t.Errorf("ParseDebug(%q) missing %s", tc.input, name)
28+ }
29+ }
30+ }
31+}
32+
33+func TestComponentLevelsAndRotation(t *testing.T) {
34+ t.Setenv("XDG_STATE_HOME", t.TempDir())
35+ path := filepath.Join(os.Getenv("XDG_STATE_HOME"), "nefertty", "nefertty.log")
36+ for run := 0; run < 2; run++ {
37+ ctx, closeLog, err := Open(context.Background(), "core")
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ core := For(ctx, "core")
42+ app := For(ctx, "app")
43+ core.Debug().Msg("visible")
44+ app.Debug().Msg("hidden")
45+ app.Info().Msg("startup")
46+ if err := closeLog(); err != nil {
47+ t.Fatal(err)
48+ }
49+ data, err := os.ReadFile(path)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ lines := splitLines(data)
54+ if len(lines) != 2 {
55+ t.Fatalf("expected 2 entries, got %q", data)
56+ }
57+ for _, line := range lines {
58+ var entry map[string]any
59+ if err := json.Unmarshal(line, &entry); err != nil {
60+ t.Fatal(err)
61+ }
62+ if entry["component"] != "core" && entry["component"] != "app" {
63+ t.Fatalf("missing component: %v", entry)
64+ }
65+ }
66+ }
67+ if _, err := os.Stat(path + ".1"); err != nil {
68+ t.Fatal(err)
69+ }
70+}
71+
72+func splitLines(data []byte) [][]byte {
73+ var lines [][]byte
74+ for _, line := range bytes.Split(data, []byte("\n")) {
75+ if len(line) > 0 {
76+ lines = append(lines, line)
77+ }
78+ }
79+ return lines
80+}
A internal/ports/doc.go +2 -0
@@ -0,0 +1,2 @@
1+// Package ports defines boundary interfaces and channel message types.
2+package ports
@@ -0,0 +1,2 @@
1+// Package ports defines boundary interfaces and channel message types.
2+package ports

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 25

Tags 10

↑↓ moveenter openesc close