main 6af0a61

logger.go RawHistoryFolder

1package logger
2
3import (
4	"fmt"
5	"io"
6	"log/slog"
7	"os"
8	"strings"
9	"time"
10
11	"github.com/bnema/archup/internal/system"
12)
13
14// Logger wraps slog.Logger and adds dry-run mode support
15type Logger struct {
16	slog    *slog.Logger
17	logPath string
18	dryRun  bool
19	logFile *os.File
20}
21
22// syncWriter wraps an io.Writer to sync after every write
23type syncWriter struct {
24	w interface {
25		io.Writer
26		Sync() error
27	}
28}
29
30func (sw *syncWriter) Write(p []byte) (n int, err error) {
31	n, err = sw.w.Write(p)
32	if err != nil {
33		return n, err
34	}
35	// Sync to disk immediately to ensure logs are written even on crash
36	if syncErr := sw.w.Sync(); syncErr != nil {
37		// Log sync error but don't fail the write
38		fmt.Fprintf(os.Stderr, "Warning: failed to sync log file: %v\n", syncErr)
39	}
40	return n, err
41}
42
43// archiveOldLog archives the existing log file if it exists and is not empty
44// Creates a timestamped backup like: /var/log/archup-install.log.2025-10-26_203045
45func archiveOldLog(logPath string) error {
46	// Check if log file exists
47	info, err := os.Stat(logPath)
48	if err != nil {
49		if os.IsNotExist(err) {
50			// No log file to archive
51			return nil
52		}
53		return fmt.Errorf("failed to stat log file: %w", err)
54	}
55
56	// If log file is empty, just truncate it
57	if info.Size() == 0 {
58		return nil
59	}
60
61	// Create archive filename with timestamp
62	timestamp := time.Now().Format("2006-01-02_150405")
63	archivePath := fmt.Sprintf("%s.%s", logPath, timestamp)
64
65	// Rename old log to archive
66	if err := os.Rename(logPath, archivePath); err != nil {
67		return fmt.Errorf("failed to rename log to archive: %w", err)
68	}
69
70	return nil
71}
72
73// New creates a new Logger with optional file logging and dry-run mode
74func New(logPath string, dryRun bool) (*Logger, error) {
75	// Archive old log if it exists and is not empty
76	if err := archiveOldLog(logPath); err != nil {
77		// Non-fatal error - just warn and continue
78		fmt.Fprintf(os.Stderr, "Warning: failed to archive old log: %v\n", err)
79	}
80
81	// Open log file for writing with sync flag for immediate writes
82	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_SYNC, 0644)
83	if err != nil {
84		return nil, fmt.Errorf("failed to open log file: %w", err)
85	}
86
87	// Wrap log file with sync writer to ensure immediate disk writes
88	syncLogFile := &syncWriter{w: logFile}
89
90	// Only write to log file (not stdout) to avoid interfering with TUI
91	// If you need stdout logging for debugging, add it back temporarily
92	handler := slog.NewTextHandler(syncLogFile, &slog.HandlerOptions{
93		Level: slog.LevelInfo,
94	})
95
96	logger := &Logger{
97		slog:    slog.New(handler),
98		logPath: logPath,
99		logFile: logFile,
100		dryRun:  dryRun,
101	}
102
103	// Write initial log entry to verify logging works
104	logger.Info("Logger initialized", "log_path", logPath, "dry_run", dryRun)
105
106	return logger, nil
107}
108
109// NewFromSlog creates a Logger from an existing slog.Logger
110// Note: This does not create a log file - the provided slog.Logger should handle output
111func NewFromSlog(slogLogger *slog.Logger, logPath string, dryRun bool) *Logger {
112	return &Logger{
113		slog:    slogLogger,
114		logPath: logPath,
115		logFile: nil, // No log file for this constructor
116		dryRun:  dryRun,
117	}
118}
119
120// Info logs an informational message
121func (l *Logger) Info(msg string, args ...any) {
122	l.slog.Info(msg, args...)
123}
124
125// Warn logs a warning message
126func (l *Logger) Warn(msg string, args ...any) {
127	l.slog.Warn(msg, args...)
128}
129
130// Error logs an error message
131func (l *Logger) Error(msg string, args ...any) {
132	l.slog.Error(msg, args...)
133}
134
135// Debug logs a debug message
136func (l *Logger) Debug(msg string, args ...any) {
137	l.slog.Debug(msg, args...)
138}
139
140// IsDryRun returns true if in dry-run mode
141func (l *Logger) IsDryRun() bool {
142	return l.dryRun
143}
144
145// LogPath returns the log file path
146func (l *Logger) LogPath() string {
147	return l.logPath
148}
149
150// ExecCommand executes a command, or logs it in dry-run mode
151func (l *Logger) ExecCommand(command string, args ...string) system.CommandResult {
152	if l.dryRun {
153		l.slog.Info("DRY-RUN", "command", command, "args", args)
154		return system.CommandResult{
155			ExitCode: 0,
156			Output:   fmt.Sprintf("DRY-RUN: %s %s", command, strings.Join(args, " ")),
157			Error:    nil,
158		}
159	}
160
161	return system.Run(system.RunConfig{
162		Command:     command,
163		Args:        args,
164		StreamToLog: true,
165		LogPath:     l.logPath,
166	})
167}
168
169// ExecSimple executes a command without logging to file
170func (l *Logger) ExecSimple(command string, args ...string) system.CommandResult {
171	if l.dryRun {
172		l.slog.Info("DRY-RUN", "command", command, "args", args)
173		return system.CommandResult{
174			ExitCode: 0,
175			Output:   fmt.Sprintf("DRY-RUN: %s %s", command, strings.Join(args, " ")),
176			Error:    nil,
177		}
178	}
179
180	return system.RunSimple(command, args...)
181}
182
183// ExecWithConfig executes a command with custom RunConfig
184func (l *Logger) ExecWithConfig(cfg system.RunConfig) system.CommandResult {
185	if l.dryRun {
186		l.slog.Info("DRY-RUN", "command", cfg.Command, "args", cfg.Args)
187		return system.CommandResult{
188			ExitCode: 0,
189			Output:   fmt.Sprintf("DRY-RUN: %s %s", cfg.Command, strings.Join(cfg.Args, " ")),
190			Error:    nil,
191		}
192	}
193
194	// Set log path if not already set
195	if cfg.LogPath == "" {
196		cfg.LogPath = l.logPath
197		cfg.StreamToLog = true
198	}
199
200	return system.Run(cfg)
201}
202
203// Slog returns the underlying slog.Logger for advanced usage
204func (l *Logger) Slog() *slog.Logger {
205	return l.slog
206}
207
208// Close closes the log file (call this when done)
209func (l *Logger) Close() error {
210	if l.logFile != nil {
211		return l.logFile.Close()
212	}
213	return nil
214}

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 1

Tags 0

    ↑↓ moveenter openesc close