detached 311217f

Merge pull request #13 from bnema/fix/copilot-review-pr11

fix(logs): address Copilot review items from PR #11
commit
311217f857d2a81881c340eeb9c6ebd65799555e
author
bnema · · 2026-01-14 21:46 UTC
committer
GitHub · · 2026-01-14 21:46 UTC
merge of
af6e1a6 + 4d087d0 · diff against the first parent
signature
unsigned
4 files · +68 -21
M internal/adapters/in/cli/auth.go +2 -3
@@ -6,7 +6,6 @@ import (
66 "fmt"
77 "os"
88 "strings"
9- "syscall"
109 "time"
1110
1211 "github.com/bnema/zerowrap"
@@ -272,8 +271,8 @@ func runTokenRevoke(tokenID, configPath string) error {
272271 func runPasswordHash() error {
273272 fmt.Print("Enter password: ")
274273
275- // Read password without echo
276- passwordBytes, err := term.ReadPassword(syscall.Stdin)
274+ // Read password without echo (use os.Stdin.Fd() for better compatibility)
275+ passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
277276 if err != nil {
278277 // Fallback for non-terminal input
279278 reader := bufio.NewReader(os.Stdin)
@@ -6,7 +6,6 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "strings"8 "strings"
9- "syscall"
10 "time"9 "time"
11 10
12 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -272,8 +271,8 @@ func runTokenRevoke(tokenID, configPath string) error {
272 func runPasswordHash() error {271 func runPasswordHash() error {
273 fmt.Print("Enter password: ")272 fmt.Print("Enter password: ")
274 273
275- // Read password without echo274+ // Read password without echo (use os.Stdin.Fd() for better compatibility)
276- passwordBytes, err := term.ReadPassword(syscall.Stdin)275+ passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
277 if err != nil {276 if err != nil {
278 // Fallback for non-terminal input277 // Fallback for non-terminal input
279 reader := bufio.NewReader(os.Stdin)278 reader := bufio.NewReader(os.Stdin)
M internal/app/logs.go +53 -17
@@ -6,7 +6,9 @@ import (
66 "fmt"
77 "os"
88 "os/exec"
9+ "os/signal"
910 "path/filepath"
11+ "syscall"
1012 "time"
1113
1214 "github.com/spf13/viper"
@@ -91,37 +93,59 @@ func showFileLog(logPath string, follow bool, lines int) error {
9193 return followFile(logPath)
9294 }
9395
94-// tailFile reads the last N lines from a file.
96+// tailFile reads the last N lines from a file using a ring buffer.
97+// This is memory-efficient as it only keeps the last N lines in memory,
98+// regardless of total file size.
9599 func tailFile(path string, lines int) error {
100+ if lines <= 0 {
101+ return nil
102+ }
103+
96104 file, err := os.Open(path)
97105 if err != nil {
98106 return fmt.Errorf("failed to open log file: %w", err)
99107 }
100108 defer file.Close()
101109
102- // Read all lines into a buffer
103- var allLines []string
104110 scanner := bufio.NewScanner(file)
111+
112+ // Use a ring buffer to store only the last N lines in memory
113+ buffer := make([]string, lines)
114+ index := 0
115+ total := 0
116+
105117 for scanner.Scan() {
106- allLines = append(allLines, scanner.Text())
118+ buffer[index] = scanner.Text()
119+ index = (index + 1) % lines
120+ total++
107121 }
108122 if err := scanner.Err(); err != nil {
109123 return fmt.Errorf("failed to read log file: %w", err)
110124 }
111125
112- // Print last N lines
113- start := 0
114- if len(allLines) > lines {
115- start = len(allLines) - lines
126+ if total == 0 {
127+ return nil
116128 }
117- for _, line := range allLines[start:] {
118- fmt.Println(line)
129+
130+ // Print lines in correct order
131+ if total <= lines {
132+ // Fewer than N lines read; buffer is partially filled, in order from index 0
133+ for i := 0; i < total; i++ {
134+ fmt.Println(buffer[i])
135+ }
136+ } else {
137+ // More than N lines read; buffer is full ring starting at index
138+ for i := 0; i < lines; i++ {
139+ pos := (index + i) % lines
140+ fmt.Println(buffer[pos])
141+ }
119142 }
120143
121144 return nil
122145 }
123146
124147 // followFile watches a file for new content and prints it.
148+// It handles interrupt signals (Ctrl+C) gracefully to ensure proper cleanup.
125149 func followFile(path string) error {
126150 file, err := os.Open(path)
127151 if err != nil {
@@ -135,17 +159,29 @@ func followFile(path string) error {
135159 return fmt.Errorf("failed to seek to end of file: %w", err)
136160 }
137161
162+ // Set up signal handling for graceful shutdown
163+ sigChan := make(chan os.Signal, 1)
164+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
165+ defer signal.Stop(sigChan)
166+
138167 reader := bufio.NewReader(file)
139168
140- // Poll for new content
169+ // Poll for new content with signal checking
141170 for {
142- line, err := reader.ReadString('\n')
143- if err != nil {
144- // No new content, wait and retry
145- time.Sleep(100 * time.Millisecond)
146- continue
171+ select {
172+ case <-sigChan:
173+ // Graceful exit on interrupt
174+ fmt.Println() // Print newline for cleaner terminal output
175+ return nil
176+ default:
177+ line, err := reader.ReadString('\n')
178+ if err != nil {
179+ // No new content, wait and retry
180+ time.Sleep(100 * time.Millisecond)
181+ continue
182+ }
183+ fmt.Print(line)
147184 }
148- fmt.Print(line)
149185 }
150186 }
151187
@@ -6,7 +6,9 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "os/exec"8 "os/exec"
9+ "os/signal"
9 "path/filepath"10 "path/filepath"
11+ "syscall"
10 "time"12 "time"
11 13
12 "github.com/spf13/viper"14 "github.com/spf13/viper"
@@ -91,37 +93,59 @@ func showFileLog(logPath string, follow bool, lines int) error {
91 return followFile(logPath)93 return followFile(logPath)
92 }94 }
93 95
94-// tailFile reads the last N lines from a file.96+// tailFile reads the last N lines from a file using a ring buffer.
97+// This is memory-efficient as it only keeps the last N lines in memory,
98+// regardless of total file size.
95 func tailFile(path string, lines int) error {99 func tailFile(path string, lines int) error {
100+ if lines <= 0 {
101+ return nil
102+ }
103+
96 file, err := os.Open(path)104 file, err := os.Open(path)
97 if err != nil {105 if err != nil {
98 return fmt.Errorf("failed to open log file: %w", err)106 return fmt.Errorf("failed to open log file: %w", err)
99 }107 }
100 defer file.Close()108 defer file.Close()
101 109
102- // Read all lines into a buffer
103- var allLines []string
104 scanner := bufio.NewScanner(file)110 scanner := bufio.NewScanner(file)
111+
112+ // Use a ring buffer to store only the last N lines in memory
113+ buffer := make([]string, lines)
114+ index := 0
115+ total := 0
116+
105 for scanner.Scan() {117 for scanner.Scan() {
106- allLines = append(allLines, scanner.Text())118+ buffer[index] = scanner.Text()
119+ index = (index + 1) % lines
120+ total++
107 }121 }
108 if err := scanner.Err(); err != nil {122 if err := scanner.Err(); err != nil {
109 return fmt.Errorf("failed to read log file: %w", err)123 return fmt.Errorf("failed to read log file: %w", err)
110 }124 }
111 125
112- // Print last N lines126+ if total == 0 {
113- start := 0127+ return nil
114- if len(allLines) > lines {
115- start = len(allLines) - lines
116 }128 }
117- for _, line := range allLines[start:] {129+
118- fmt.Println(line)130+ // Print lines in correct order
131+ if total <= lines {
132+ // Fewer than N lines read; buffer is partially filled, in order from index 0
133+ for i := 0; i < total; i++ {
134+ fmt.Println(buffer[i])
135+ }
136+ } else {
137+ // More than N lines read; buffer is full ring starting at index
138+ for i := 0; i < lines; i++ {
139+ pos := (index + i) % lines
140+ fmt.Println(buffer[pos])
141+ }
119 }142 }
120 143
121 return nil144 return nil
122 }145 }
123 146
124 // followFile watches a file for new content and prints it.147 // followFile watches a file for new content and prints it.
148+// It handles interrupt signals (Ctrl+C) gracefully to ensure proper cleanup.
125 func followFile(path string) error {149 func followFile(path string) error {
126 file, err := os.Open(path)150 file, err := os.Open(path)
127 if err != nil {151 if err != nil {
@@ -135,17 +159,29 @@ func followFile(path string) error {
135 return fmt.Errorf("failed to seek to end of file: %w", err)159 return fmt.Errorf("failed to seek to end of file: %w", err)
136 }160 }
137 161
162+ // Set up signal handling for graceful shutdown
163+ sigChan := make(chan os.Signal, 1)
164+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
165+ defer signal.Stop(sigChan)
166+
138 reader := bufio.NewReader(file)167 reader := bufio.NewReader(file)
139 168
140- // Poll for new content169+ // Poll for new content with signal checking
141 for {170 for {
142- line, err := reader.ReadString('\n')171+ select {
143- if err != nil {172+ case <-sigChan:
144- // No new content, wait and retry173+ // Graceful exit on interrupt
145- time.Sleep(100 * time.Millisecond)174+ fmt.Println() // Print newline for cleaner terminal output
146- continue175+ return nil
176+ default:
177+ line, err := reader.ReadString('\n')
178+ if err != nil {
179+ // No new content, wait and retry
180+ time.Sleep(100 * time.Millisecond)
181+ continue
182+ }
183+ fmt.Print(line)
147 }184 }
148- fmt.Print(line)
149 }185 }
150 }186 }
151 187
M internal/app/run.go +8 -1
@@ -701,11 +701,18 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
701701 }
702702
703703 // runServers starts the HTTP servers and waits for shutdown.
704+// Signal handling notes:
705+// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
706+// - SIGUSR1: Triggers config reload without restart
707+// The deferred signal.Stop(reloadChan) ensures the signal handler is properly
708+// cleaned up before program exit, preventing signal handler leaks.
704709 func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
705710 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
706711 defer cancel()
707712
708- // Set up SIGUSR1 for reload
713+ // Set up SIGUSR1 for reload.
714+ // Note: signal.Stop must be called (via defer) to release the channel
715+ // and prevent signal handler leaks when the function returns.
709716 reloadChan := make(chan os.Signal, 1)
710717 signal.Notify(reloadChan, syscall.SIGUSR1)
711718 defer signal.Stop(reloadChan)
@@ -701,11 +701,18 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
701 }701 }
702 702
703 // runServers starts the HTTP servers and waits for shutdown.703 // runServers starts the HTTP servers and waits for shutdown.
704+// Signal handling notes:
705+// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
706+// - SIGUSR1: Triggers config reload without restart
707+// The deferred signal.Stop(reloadChan) ensures the signal handler is properly
708+// cleaned up before program exit, preventing signal handler leaks.
704 func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {709 func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)710 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
706 defer cancel()711 defer cancel()
707 712
708- // Set up SIGUSR1 for reload713+ // Set up SIGUSR1 for reload.
714+ // Note: signal.Stop must be called (via defer) to release the channel
715+ // and prevent signal handler leaks when the function returns.
709 reloadChan := make(chan os.Signal, 1)716 reloadChan := make(chan os.Signal, 1)
710 signal.Notify(reloadChan, syscall.SIGUSR1)717 signal.Notify(reloadChan, syscall.SIGUSR1)
711 defer signal.Stop(reloadChan)718 defer signal.Stop(reloadChan)
M internal/usecase/container/service.go +5 -0
@@ -910,6 +910,11 @@ func normalizeImageRef(image string) string {
910910 // user/repo:tag -> docker.io/user/repo
911911 // localhost:5000/image:tag -> localhost:5000/image
912912 // registry.example.com/image:tag -> registry.example.com/image
913+ //
914+ // Note: This function assumes valid Docker image references. Edge cases like
915+ // "registry.com:5000" (registry with port but no image name) are not valid
916+ // image references per Docker's naming conventions, which require at least
917+ // one path component after the registry (e.g., "registry.com:5000/image").
913918
914919 // Find the tag separator: the last colon that isn't part of a port number.
915920 // A colon is part of a port if there's no slash after it until the next colon.
@@ -910,6 +910,11 @@ func normalizeImageRef(image string) string {
910 // user/repo:tag -> docker.io/user/repo910 // user/repo:tag -> docker.io/user/repo
911 // localhost:5000/image:tag -> localhost:5000/image911 // localhost:5000/image:tag -> localhost:5000/image
912 // registry.example.com/image:tag -> registry.example.com/image912 // registry.example.com/image:tag -> registry.example.com/image
913+ //
914+ // Note: This function assumes valid Docker image references. Edge cases like
915+ // "registry.com:5000" (registry with port but no image name) are not valid
916+ // image references per Docker's naming conventions, which require at least
917+ // one path component after the registry (e.g., "registry.com:5000/image").
913 918
914 // Find the tag separator: the last colon that isn't part of a port number.919 // Find the tag separator: the last colon that isn't part of a port number.
915 // A colon is part of a port if there's no slash after it until the next colon.920 // A colon is part of a port if there's no slash after it until the next colon.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close