logger.go RawHistoryFolder

1package logging
2
3import (
4	"errors"
5	"io"
6	neturl "net/url"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11	"time"
12
13	"github.com/rs/zerolog"
14)
15
16const (
17	logFormatConsole  = "console"
18	logFormatJSON     = "json"
19	logFormatText     = "text"
20	ConsoleTimeFormat = "15:04:05"
21)
22
23// Config holds logging configuration
24type Config struct {
25	Level      zerolog.Level
26	Format     string // "json" or "console"
27	TimeFormat string
28}
29
30// FileConfig holds file logging configuration
31type FileConfig struct {
32	Enabled       bool
33	LogDir        string
34	SessionID     string
35	WriteToStderr bool // if false, logs go to file only (not stderr)
36}
37
38// DefaultConfig returns sensible defaults
39func DefaultConfig() Config {
40	return Config{
41		Level:      zerolog.InfoLevel,
42		Format:     logFormatConsole,
43		TimeFormat: time.RFC3339,
44	}
45}
46
47// New creates a new zerolog logger with the given configuration
48func New(cfg Config) zerolog.Logger {
49	var output io.Writer = os.Stderr
50
51	switch cfg.Format {
52	case logFormatConsole:
53		output = zerolog.ConsoleWriter{
54			Out:        os.Stderr,
55			TimeFormat: cfg.TimeFormat,
56		}
57	case logFormatJSON:
58		// JSON is the default zerolog format
59		output = os.Stderr
60	}
61
62	return zerolog.New(output).
63		Level(cfg.Level).
64		With().
65		Timestamp().
66		Logger()
67}
68
69// NewWithFile creates a logger that writes to stderr and/or a session log file.
70// The session file uses JSON format for easy parsing by the CLI logs command.
71// LogDir must exist before calling this function (handled by config.EnsureDirectories).
72// Returns the logger and a cleanup function to close the file.
73func NewWithFile(cfg Config, fileCfg FileConfig) (zerolog.Logger, func(), error) {
74	const (
75		logDirPerm  = 0o700
76		logFilePerm = 0o600
77	)
78	var writers []io.Writer
79	cleanup := func() {}
80
81	// Stderr output (only if enabled)
82	if fileCfg.WriteToStderr {
83		switch cfg.Format {
84		case logFormatConsole, logFormatText, "":
85			writers = append(writers, zerolog.ConsoleWriter{
86				Out:        os.Stderr,
87				TimeFormat: cfg.TimeFormat,
88			})
89		case logFormatJSON:
90			writers = append(writers, os.Stderr)
91		default:
92			writers = append(writers, zerolog.ConsoleWriter{
93				Out:        os.Stderr,
94				TimeFormat: cfg.TimeFormat,
95			})
96		}
97	}
98
99	// Session file output (JSON format for parsing)
100	if fileCfg.Enabled && fileCfg.LogDir != "" && fileCfg.SessionID != "" {
101		// Ensure log directory exists (LogDir is from config, may not have logs subdir).
102		// Session logs may contain browsing metadata, so keep them user-private.
103		if err := os.MkdirAll(fileCfg.LogDir, logDirPerm); err != nil {
104			return zerolog.Logger{}, nil, err
105		}
106		if err := os.Chmod(fileCfg.LogDir, logDirPerm); err != nil {
107			return zerolog.Logger{}, nil, err
108		}
109
110		filename := filepath.Join(fileCfg.LogDir, SessionFilename(fileCfg.SessionID))
111		file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
112		if err != nil {
113			return zerolog.Logger{}, nil, err
114		}
115		if err := file.Chmod(logFilePerm); err != nil {
116			_ = file.Close()
117			return zerolog.Logger{}, nil, err
118		}
119
120		sessionWriter := newSessionFileWriter(file)
121		writers = append(writers, sessionWriter)
122		cleanup = func() { _ = sessionWriter.Close() }
123	}
124
125	// Fallback to discard if no writers configured
126	if len(writers) == 0 {
127		writers = append(writers, io.Discard)
128	}
129
130	multi := zerolog.MultiLevelWriter(writers...)
131	ctx := zerolog.New(multi).
132		Level(cfg.Level).
133		With().
134		Timestamp()
135
136	return ctx.Logger(), cleanup, nil
137}
138
139type sessionFileWriter struct {
140	mu     sync.Mutex
141	file   *os.File
142	closed bool
143}
144
145func newSessionFileWriter(file *os.File) *sessionFileWriter {
146	return &sessionFileWriter{file: file}
147}
148
149func (w *sessionFileWriter) Write(p []byte) (int, error) {
150	w.mu.Lock()
151	defer w.mu.Unlock()
152
153	if w.closed || w.file == nil {
154		return len(p), nil
155	}
156
157	n, err := w.file.Write(p)
158	if errors.Is(err, os.ErrClosed) {
159		w.closed = true
160		w.file = nil
161		return len(p), nil
162	}
163
164	return n, err
165}
166
167func (w *sessionFileWriter) Close() error {
168	w.mu.Lock()
169	defer w.mu.Unlock()
170
171	if w.closed || w.file == nil {
172		return nil
173	}
174
175	err := w.file.Close()
176	w.closed = true
177	w.file = nil
178	if errors.Is(err, os.ErrClosed) {
179		return nil
180	}
181	return err
182}
183
184// NewFromEnv creates a logger based on environment variables
185// DUMBER_LOG_LEVEL: trace, debug, info, warn, error (default: info)
186// DUMBER_LOG_FORMAT: json, console (default: console)
187func NewFromEnv() zerolog.Logger {
188	cfg := DefaultConfig()
189
190	if level := os.Getenv("DUMBER_LOG_LEVEL"); level != "" {
191		cfg.Level = ParseLevel(level)
192	}
193
194	if format := os.Getenv("DUMBER_LOG_FORMAT"); format != "" {
195		switch format {
196		case logFormatJSON, logFormatConsole, logFormatText:
197			cfg.Format = format
198		}
199	}
200
201	return New(cfg)
202}
203
204// NewFromConfigValues creates a logger from level and format strings.
205// This is used by main.go to create a logger from the config package's LoggingConfig
206// without creating an import cycle.
207func NewFromConfigValues(level, format string) zerolog.Logger {
208	return NewFromConfigValuesWithTimeFormat(level, format, "")
209}
210
211// NewFromConfigValuesWithTimeFormat creates a logger from level/format and overrides time format.
212func NewFromConfigValuesWithTimeFormat(level, format, timeFormat string) zerolog.Logger {
213	cfg := DefaultConfig()
214	cfg.Level = ParseLevel(level)
215	if timeFormat != "" {
216		cfg.TimeFormat = timeFormat
217	}
218
219	switch format {
220	case logFormatJSON:
221		cfg.Format = logFormatJSON
222	case logFormatConsole, logFormatText, "":
223		cfg.Format = logFormatConsole
224	default:
225		cfg.Format = logFormatConsole
226	}
227
228	return New(cfg)
229}
230
231// ParseLevel converts a level string to zerolog.Level
232func ParseLevel(level string) zerolog.Level {
233	switch level {
234	case "trace":
235		return zerolog.TraceLevel
236	case "debug":
237		return zerolog.DebugLevel
238	case "info", "":
239		return zerolog.InfoLevel
240	case "warn":
241		return zerolog.WarnLevel
242	case "error":
243		return zerolog.ErrorLevel
244	case "fatal":
245		return zerolog.FatalLevel
246	default:
247		return zerolog.InfoLevel
248	}
249}
250
251// PermissionLogURLMaxLen is the standard max length for permission-related URL logging.
252const PermissionLogURLMaxLen = 96
253
254// RedactedURLPlaceholder is emitted when input looks like a URL/path but has no safe host.
255const RedactedURLPlaceholder = "[redacted-url]"
256
257// RedactURL returns privacy-preserving URL diagnostics. Absolute URLs with a
258// host are reduced to host-only; query strings, fragments, paths, credentials,
259// and local file paths are omitted.
260func RedactURL(raw string) string {
261	raw = strings.TrimSpace(raw)
262	if raw == "" {
263		return ""
264	}
265
266	parsed, err := neturl.Parse(raw)
267	if err != nil {
268		return RedactedURLPlaceholder
269	}
270	if parsed.Host != "" {
271		return parsed.Hostname()
272	}
273	if parsed.Scheme == "about" && parsed.Opaque != "" {
274		// Use Opaque directly instead of parsed.String() to avoid leaking
275		// RawQuery and Fragment (Go's URL parser separates them from Opaque,
276		// but String() reconstructs the full URL including query/fragment).
277		return parsed.Scheme + ":" + parsed.Opaque
278	}
279	if parsed.Scheme != "" {
280		return parsed.Scheme + ":" + RedactedURLPlaceholder
281	}
282	return RedactedURLPlaceholder
283}
284
285// TruncateURL redacts and then truncates a URL for logging.
286func TruncateURL(url string, maxLen int) string {
287	redacted := RedactURL(url)
288	if len(redacted) <= maxLen {
289		return redacted
290	}
291	return redacted[:maxLen] + "..."
292}
293
294// SafeURLHost returns only the host portion of a URL for privacy-preserving diagnostics.
295func SafeURLHost(raw string) string {
296	parsed, err := neturl.Parse(raw)
297	if err != nil {
298		return ""
299	}
300	return parsed.Hostname()
301}

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 8

Tags 42

↑↓ moveenter openesc close