detached 4cc8b0e

Add count-based session log cleanup

commit
4cc8b0e464cc4d34809c19fb0023683db5ad3e70
author
brice · · 2026-04-24 03:40 UTC
parent
716a5ee
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +290 -4
M docs/config/index.md +1 -0
@@ -126,6 +126,7 @@ auto_open_on_new_pane = false
126126 | `logging.level` | string | `"info"` | `debug`, `info`, `warn`, `error` | Log level |
127127 | `logging.format` | string | `"text"` | `text`, `json` | Log format |
128128 | `logging.max_age` | int | `7` | >= 0 | Days to keep logs |
129+| `logging.max_files` | int | `100` | >= 0 | Session log files to keep (`0` disables count cleanup) |
129130 | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory |
130131 | `logging.enable_file_log` | bool | `true` | - | Enable file logging |
131132 | `logging.capture_console` | bool | `false` | - | Capture browser console to logs |
@@ -126,6 +126,7 @@ auto_open_on_new_pane = false
126 | `logging.level` | string | `"info"` | `debug`, `info`, `warn`, `error` | Log level |126 | `logging.level` | string | `"info"` | `debug`, `info`, `warn`, `error` | Log level |
127 | `logging.format` | string | `"text"` | `text`, `json` | Log format |127 | `logging.format` | string | `"text"` | `text`, `json` | Log format |
128 | `logging.max_age` | int | `7` | >= 0 | Days to keep logs |128 | `logging.max_age` | int | `7` | >= 0 | Days to keep logs |
129+| `logging.max_files` | int | `100` | >= 0 | Session log files to keep (`0` disables count cleanup) |
129 | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory |130 | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory |
130 | `logging.enable_file_log` | bool | `true` | - | Enable file logging |131 | `logging.enable_file_log` | bool | `true` | - | Enable file logging |
131 | `logging.capture_console` | bool | `false` | - | Capture browser console to logs |132 | `logging.capture_console` | bool | `false` | - | Capture browser console to logs |
M docs/reference/configuration.md +1 -0
@@ -23,6 +23,7 @@
2323 | `logging.level` | string | `info` | `debug`, `info`, `warn`, `error` |
2424 | `logging.format` | string | `text` | `text`, `json` |
2525 | `logging.max_age` | int | `7` | >= 0 |
26+| `logging.max_files` | int | `100` | >= 0 |
2627 | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | |
2728 | `logging.enable_file_log` | bool | `true` | |
2829 | `logging.capture_console` | bool | `false` | |
@@ -23,6 +23,7 @@
23 | `logging.level` | string | `info` | `debug`, `info`, `warn`, `error` |23 | `logging.level` | string | `info` | `debug`, `info`, `warn`, `error` |
24 | `logging.format` | string | `text` | `text`, `json` |24 | `logging.format` | string | `text` | `text`, `json` |
25 | `logging.max_age` | int | `7` | >= 0 |25 | `logging.max_age` | int | `7` | >= 0 |
26+| `logging.max_files` | int | `100` | >= 0 |
26 | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | |27 | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | |
27 | `logging.enable_file_log` | bool | `true` | |28 | `logging.enable_file_log` | bool | `true` | |
28 | `logging.capture_console` | bool | `false` | |29 | `logging.capture_console` | bool | `false` | |
M internal/bootstrap/session.go +16 -0
@@ -348,6 +348,22 @@ func runSessionCleanup(
348348
349349 // Prune old marker files left by previous versions.
350350 sweepLegacyMarkers(cfg.Logging.LogDir, log)
351+
352+ removedLogs, logCleanupErr := corelogging.CleanupSessionLogFiles(
353+ cfg.Logging.LogDir,
354+ cfg.Logging.MaxFiles,
355+ string(currentSessionID),
356+ )
357+ if logCleanupErr != nil {
358+ if log != nil {
359+ log.Warn().Err(logCleanupErr).Msg("background: failed to cleanup session log files")
360+ }
361+ } else if removedLogs > 0 && log != nil {
362+ log.Info().
363+ Int("deleted", removedLogs).
364+ Int("max_files", cfg.Logging.MaxFiles).
365+ Msg("background: cleaned up session log files")
366+ }
351367 }
352368
353369 // sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
@@ -348,6 +348,22 @@ func runSessionCleanup(
348 348
349 // Prune old marker files left by previous versions.349 // Prune old marker files left by previous versions.
350 sweepLegacyMarkers(cfg.Logging.LogDir, log)350 sweepLegacyMarkers(cfg.Logging.LogDir, log)
351+
352+ removedLogs, logCleanupErr := corelogging.CleanupSessionLogFiles(
353+ cfg.Logging.LogDir,
354+ cfg.Logging.MaxFiles,
355+ string(currentSessionID),
356+ )
357+ if logCleanupErr != nil {
358+ if log != nil {
359+ log.Warn().Err(logCleanupErr).Msg("background: failed to cleanup session log files")
360+ }
361+ } else if removedLogs > 0 && log != nil {
362+ log.Info().
363+ Int("deleted", removedLogs).
364+ Int("max_files", cfg.Logging.MaxFiles).
365+ Msg("background: cleaned up session log files")
366+ }
351 }367 }
352 368
353 // sweepLegacyMarkers removes .startup.marker, .shutdown.marker,369 // sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
M internal/infrastructure/config/defaults.go +3 -1
@@ -10,7 +10,8 @@ const (
1010 defaultMaxHistoryDays = 30 // days
1111
1212 // Logging defaults
13- defaultMaxLogAgeDays = 7 // days
13+ defaultMaxLogAgeDays = 7 // days
14+ defaultMaxLogFiles = 100 // session log files
1415
1516 // Appearance defaults
1617 defaultFontSize = 16 // points
@@ -117,6 +118,7 @@ func DefaultConfig() *Config {
117118 Level: "info",
118119 Format: "text", // text or json
119120 MaxAge: defaultMaxLogAgeDays,
121+ MaxFiles: defaultMaxLogFiles,
120122 LogDir: getDefaultLogDir(),
121123 EnableFileLog: true,
122124 CaptureConsole: false, // Disabled by default
@@ -10,7 +10,8 @@ const (
10 defaultMaxHistoryDays = 30 // days10 defaultMaxHistoryDays = 30 // days
11 11
12 // Logging defaults12 // Logging defaults
13- defaultMaxLogAgeDays = 7 // days13+ defaultMaxLogAgeDays = 7 // days
14+ defaultMaxLogFiles = 100 // session log files
14 15
15 // Appearance defaults16 // Appearance defaults
16 defaultFontSize = 16 // points17 defaultFontSize = 16 // points
@@ -117,6 +118,7 @@ func DefaultConfig() *Config {
117 Level: "info",118 Level: "info",
118 Format: "text", // text or json119 Format: "text", // text or json
119 MaxAge: defaultMaxLogAgeDays,120 MaxAge: defaultMaxLogAgeDays,
121+ MaxFiles: defaultMaxLogFiles,
120 LogDir: getDefaultLogDir(),122 LogDir: getDefaultLogDir(),
121 EnableFileLog: true,123 EnableFileLog: true,
122 CaptureConsole: false, // Disabled by default124 CaptureConsole: false, // Disabled by default
M internal/infrastructure/config/defaults_test.go +1 -0
@@ -9,6 +9,7 @@ import (
99 func TestDefaultConfig_CoreDefaults(t *testing.T) {
1010 cfg := DefaultConfig()
1111 assert.Equal(t, "info", cfg.Logging.Level)
12+ assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
1213 assert.False(t, cfg.Logging.CaptureGTKLogs)
1314 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
1415 assert.False(t, cfg.Transcoding.Enabled)
@@ -9,6 +9,7 @@ import (
9 func TestDefaultConfig_CoreDefaults(t *testing.T) {9 func TestDefaultConfig_CoreDefaults(t *testing.T) {
10 cfg := DefaultConfig()10 cfg := DefaultConfig()
11 assert.Equal(t, "info", cfg.Logging.Level)11 assert.Equal(t, "info", cfg.Logging.Level)
12+ assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
12 assert.False(t, cfg.Logging.CaptureGTKLogs)13 assert.False(t, cfg.Logging.CaptureGTKLogs)
13 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)14 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
14 assert.False(t, cfg.Transcoding.Enabled)15 assert.False(t, cfg.Transcoding.Enabled)
M internal/infrastructure/config/loader.go +1 -0
@@ -453,6 +453,7 @@ func (m *Manager) setLoggingDefaults(defaults *Config) {
453453 m.viper.SetDefault("logging.level", defaults.Logging.Level)
454454 m.viper.SetDefault("logging.format", defaults.Logging.Format)
455455 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
456+ m.viper.SetDefault("logging.max_files", defaults.Logging.MaxFiles)
456457 m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
457458 m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
458459 m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
@@ -453,6 +453,7 @@ func (m *Manager) setLoggingDefaults(defaults *Config) {
453 m.viper.SetDefault("logging.level", defaults.Logging.Level)453 m.viper.SetDefault("logging.level", defaults.Logging.Level)
454 m.viper.SetDefault("logging.format", defaults.Logging.Format)454 m.viper.SetDefault("logging.format", defaults.Logging.Format)
455 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)455 m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
456+ m.viper.SetDefault("logging.max_files", defaults.Logging.MaxFiles)
456 m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)457 m.viper.SetDefault("logging.log_dir", defaults.Logging.LogDir)
457 m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)458 m.viper.SetDefault("logging.enable_file_log", defaults.Logging.EnableFileLog)
458 m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)459 m.viper.SetDefault("logging.capture_console", defaults.Logging.CaptureConsole)
M internal/infrastructure/config/schema.go +4 -3
@@ -320,9 +320,10 @@ type DmenuConfig struct {
320320
321321 // LoggingConfig holds logging configuration.
322322 type LoggingConfig struct {
323- Level string `mapstructure:"level" yaml:"level" toml:"level"`
324- Format string `mapstructure:"format" yaml:"format" toml:"format"`
325- MaxAge int `mapstructure:"max_age" yaml:"max_age" toml:"max_age"`
323+ Level string `mapstructure:"level" yaml:"level" toml:"level"`
324+ Format string `mapstructure:"format" yaml:"format" toml:"format"`
325+ MaxAge int `mapstructure:"max_age" yaml:"max_age" toml:"max_age"`
326+ MaxFiles int `mapstructure:"max_files" yaml:"max_files" toml:"max_files"`
326327
327328 // File output configuration
328329 LogDir string `mapstructure:"log_dir" yaml:"log_dir" toml:"log_dir"`
@@ -320,9 +320,10 @@ type DmenuConfig struct {
320 320
321 // LoggingConfig holds logging configuration.321 // LoggingConfig holds logging configuration.
322 type LoggingConfig struct {322 type LoggingConfig struct {
323- Level string `mapstructure:"level" yaml:"level" toml:"level"`323+ Level string `mapstructure:"level" yaml:"level" toml:"level"`
324- Format string `mapstructure:"format" yaml:"format" toml:"format"`324+ Format string `mapstructure:"format" yaml:"format" toml:"format"`
325- MaxAge int `mapstructure:"max_age" yaml:"max_age" toml:"max_age"`325+ MaxAge int `mapstructure:"max_age" yaml:"max_age" toml:"max_age"`
326+ MaxFiles int `mapstructure:"max_files" yaml:"max_files" toml:"max_files"`
326 327
327 // File output configuration328 // File output configuration
328 LogDir string `mapstructure:"log_dir" yaml:"log_dir" toml:"log_dir"`329 LogDir string `mapstructure:"log_dir" yaml:"log_dir" toml:"log_dir"`
M internal/infrastructure/config/schema_provider.go +8 -0
@@ -197,6 +197,14 @@ func (*SchemaProvider) getLoggingKeys(defaults *Config) []entity.ConfigKeyInfo {
197197 Range: ">=0",
198198 Section: SectionLogging,
199199 },
200+ {
201+ Key: "logging.max_files",
202+ Type: "int",
203+ Default: fmt.Sprintf("%d", defaults.Logging.MaxFiles),
204+ Description: "Maximum number of session log files to keep (0 disables count-based cleanup)",
205+ Range: ">=0",
206+ Section: SectionLogging,
207+ },
200208 {
201209 Key: "logging.log_dir",
202210 Type: "string",
@@ -197,6 +197,14 @@ func (*SchemaProvider) getLoggingKeys(defaults *Config) []entity.ConfigKeyInfo {
197 Range: ">=0",197 Range: ">=0",
198 Section: SectionLogging,198 Section: SectionLogging,
199 },199 },
200+ {
201+ Key: "logging.max_files",
202+ Type: "int",
203+ Default: fmt.Sprintf("%d", defaults.Logging.MaxFiles),
204+ Description: "Maximum number of session log files to keep (0 disables count-based cleanup)",
205+ Range: ">=0",
206+ Section: SectionLogging,
207+ },
200 {208 {
201 Key: "logging.log_dir",209 Key: "logging.log_dir",
202 Type: "string",210 Type: "string",
M internal/infrastructure/config/validation.go +3 -0
@@ -308,6 +308,9 @@ func validateLogging(config *Config) []string {
308308 if config.Logging.MaxAge < 0 {
309309 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
310310 }
311+ if config.Logging.MaxFiles < 0 {
312+ validationErrors = append(validationErrors, "logging.max_files must be non-negative")
313+ }
311314 switch config.Logging.Level {
312315 case "trace", "debug", "info", "warn", "error", "fatal", "":
313316 default:
@@ -308,6 +308,9 @@ func validateLogging(config *Config) []string {
308 if config.Logging.MaxAge < 0 {308 if config.Logging.MaxAge < 0 {
309 validationErrors = append(validationErrors, "logging.max_age must be non-negative")309 validationErrors = append(validationErrors, "logging.max_age must be non-negative")
310 }310 }
311+ if config.Logging.MaxFiles < 0 {
312+ validationErrors = append(validationErrors, "logging.max_files must be non-negative")
313+ }
311 switch config.Logging.Level {314 switch config.Logging.Level {
312 case "trace", "debug", "info", "warn", "error", "fatal", "":315 case "trace", "debug", "info", "warn", "error", "fatal", "":
313 default:316 default:
M internal/logging/session.go +91 -0
@@ -3,6 +3,11 @@ package logging
33 import (
44 "crypto/rand"
55 "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sort"
611 "time"
712 )
813
@@ -49,3 +54,89 @@ func ParseSessionFilename(filename string) (sessionID string, ok bool) {
4954 func SessionFilename(sessionID string) string {
5055 return "session_" + sessionID + ".log"
5156 }
57+
58+type sessionLogFile struct {
59+ path string
60+ filename string
61+ sessionID string
62+ modTime time.Time
63+}
64+
65+// CleanupSessionLogFiles removes old session log files when their count exceeds maxFiles.
66+// It keeps the newest files by modification time and never removes the supplied session IDs.
67+// A maxFiles value of 0 disables count-based cleanup.
68+func CleanupSessionLogFiles(logDir string, maxFiles int, keepSessionIDs ...string) (int, error) {
69+ if logDir == "" || maxFiles <= 0 {
70+ return 0, nil
71+ }
72+
73+ entries, err := os.ReadDir(logDir)
74+ if err != nil {
75+ if os.IsNotExist(err) {
76+ return 0, nil
77+ }
78+ return 0, fmt.Errorf("read log directory: %w", err)
79+ }
80+
81+ keep := make(map[string]struct{}, len(keepSessionIDs))
82+ for _, sessionID := range keepSessionIDs {
83+ if sessionID == "" {
84+ continue
85+ }
86+ keep[sessionID] = struct{}{}
87+ }
88+
89+ files := make([]sessionLogFile, 0, len(entries))
90+ operationErrs := make([]error, 0)
91+ for _, entry := range entries {
92+ if entry.IsDir() {
93+ continue
94+ }
95+
96+ sessionID, ok := ParseSessionFilename(entry.Name())
97+ if !ok {
98+ continue
99+ }
100+
101+ info, infoErr := entry.Info()
102+ if infoErr != nil {
103+ operationErrs = append(operationErrs, fmt.Errorf("stat %s: %w", filepath.Join(logDir, entry.Name()), infoErr))
104+ continue
105+ }
106+
107+ files = append(files, sessionLogFile{
108+ path: filepath.Join(logDir, entry.Name()),
109+ filename: entry.Name(),
110+ sessionID: sessionID,
111+ modTime: info.ModTime(),
112+ })
113+ }
114+
115+ sort.Slice(files, func(i, j int) bool {
116+ if files[i].modTime.Equal(files[j].modTime) {
117+ return files[i].filename > files[j].filename
118+ }
119+ return files[i].modTime.After(files[j].modTime)
120+ })
121+
122+ kept := 0
123+ removed := 0
124+ for _, file := range files {
125+ if _, shouldKeep := keep[file.sessionID]; shouldKeep {
126+ kept++
127+ continue
128+ }
129+ if kept < maxFiles {
130+ kept++
131+ continue
132+ }
133+
134+ if err := os.Remove(file.path); err != nil {
135+ operationErrs = append(operationErrs, fmt.Errorf("remove %s: %w", file.path, err))
136+ continue
137+ }
138+ removed++
139+ }
140+
141+ return removed, errors.Join(operationErrs...)
142+}
@@ -3,6 +3,11 @@ package logging
3 import (3 import (
4 "crypto/rand"4 "crypto/rand"
5 "encoding/hex"5 "encoding/hex"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "sort"
6 "time"11 "time"
7 )12 )
8 13
@@ -49,3 +54,89 @@ func ParseSessionFilename(filename string) (sessionID string, ok bool) {
49 func SessionFilename(sessionID string) string {54 func SessionFilename(sessionID string) string {
50 return "session_" + sessionID + ".log"55 return "session_" + sessionID + ".log"
51 }56 }
57+
58+type sessionLogFile struct {
59+ path string
60+ filename string
61+ sessionID string
62+ modTime time.Time
63+}
64+
65+// CleanupSessionLogFiles removes old session log files when their count exceeds maxFiles.
66+// It keeps the newest files by modification time and never removes the supplied session IDs.
67+// A maxFiles value of 0 disables count-based cleanup.
68+func CleanupSessionLogFiles(logDir string, maxFiles int, keepSessionIDs ...string) (int, error) {
69+ if logDir == "" || maxFiles <= 0 {
70+ return 0, nil
71+ }
72+
73+ entries, err := os.ReadDir(logDir)
74+ if err != nil {
75+ if os.IsNotExist(err) {
76+ return 0, nil
77+ }
78+ return 0, fmt.Errorf("read log directory: %w", err)
79+ }
80+
81+ keep := make(map[string]struct{}, len(keepSessionIDs))
82+ for _, sessionID := range keepSessionIDs {
83+ if sessionID == "" {
84+ continue
85+ }
86+ keep[sessionID] = struct{}{}
87+ }
88+
89+ files := make([]sessionLogFile, 0, len(entries))
90+ operationErrs := make([]error, 0)
91+ for _, entry := range entries {
92+ if entry.IsDir() {
93+ continue
94+ }
95+
96+ sessionID, ok := ParseSessionFilename(entry.Name())
97+ if !ok {
98+ continue
99+ }
100+
101+ info, infoErr := entry.Info()
102+ if infoErr != nil {
103+ operationErrs = append(operationErrs, fmt.Errorf("stat %s: %w", filepath.Join(logDir, entry.Name()), infoErr))
104+ continue
105+ }
106+
107+ files = append(files, sessionLogFile{
108+ path: filepath.Join(logDir, entry.Name()),
109+ filename: entry.Name(),
110+ sessionID: sessionID,
111+ modTime: info.ModTime(),
112+ })
113+ }
114+
115+ sort.Slice(files, func(i, j int) bool {
116+ if files[i].modTime.Equal(files[j].modTime) {
117+ return files[i].filename > files[j].filename
118+ }
119+ return files[i].modTime.After(files[j].modTime)
120+ })
121+
122+ kept := 0
123+ removed := 0
124+ for _, file := range files {
125+ if _, shouldKeep := keep[file.sessionID]; shouldKeep {
126+ kept++
127+ continue
128+ }
129+ if kept < maxFiles {
130+ kept++
131+ continue
132+ }
133+
134+ if err := os.Remove(file.path); err != nil {
135+ operationErrs = append(operationErrs, fmt.Errorf("remove %s: %w", file.path, err))
136+ continue
137+ }
138+ removed++
139+ }
140+
141+ return removed, errors.Join(operationErrs...)
142+}
A internal/logging/session_test.go +161 -0
@@ -0,0 +1,161 @@
1+package logging
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+)
9+
10+func TestCleanupSessionLogFiles_RemovesOldestSessionLogs(t *testing.T) {
11+ t.Parallel()
12+
13+ dir := t.TempDir()
14+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
15+
16+ oldest := writeSessionLog(t, dir, "20260424_120000_0001", base)
17+ secondOldest := writeSessionLog(t, dir, "20260424_120001_0002", base.Add(time.Second))
18+ secondNewest := writeSessionLog(t, dir, "20260424_120002_0003", base.Add(2*time.Second))
19+ newest := writeSessionLog(t, dir, "20260424_120003_0004", base.Add(3*time.Second))
20+ nonSession := filepath.Join(dir, "dumber.log")
21+ if err := os.WriteFile(nonSession, []byte("keep me"), 0o644); err != nil {
22+ t.Fatalf("write non-session log: %v", err)
23+ }
24+
25+ removed, err := CleanupSessionLogFiles(dir, 2)
26+ if err != nil {
27+ t.Fatalf("cleanup session log files: %v", err)
28+ }
29+ if removed != 2 {
30+ t.Fatalf("removed = %d, want 2", removed)
31+ }
32+
33+ assertMissing(t, oldest)
34+ assertMissing(t, secondOldest)
35+ assertExists(t, secondNewest)
36+ assertExists(t, newest)
37+ assertExists(t, nonSession)
38+}
39+
40+func TestCleanupSessionLogFiles_DisabledWhenMaxFilesZero(t *testing.T) {
41+ t.Parallel()
42+
43+ dir := t.TempDir()
44+ path := writeSessionLog(t, dir, "20260424_120000_0001", time.Now())
45+
46+ removed, err := CleanupSessionLogFiles(dir, 0)
47+ if err != nil {
48+ t.Fatalf("cleanup session log files: %v", err)
49+ }
50+ if removed != 0 {
51+ t.Fatalf("removed = %d, want 0", removed)
52+ }
53+ assertExists(t, path)
54+}
55+
56+func TestCleanupSessionLogFiles_NoRemovalWhenUnderLimit(t *testing.T) {
57+ t.Parallel()
58+
59+ dir := t.TempDir()
60+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
61+
62+ first := writeSessionLog(t, dir, "20260424_120000_0001", base)
63+ second := writeSessionLog(t, dir, "20260424_120001_0002", base.Add(time.Second))
64+
65+ removed, err := CleanupSessionLogFiles(dir, 3)
66+ if err != nil {
67+ t.Fatalf("cleanup session log files: %v", err)
68+ }
69+ if removed != 0 {
70+ t.Fatalf("removed = %d, want 0", removed)
71+ }
72+
73+ assertExists(t, first)
74+ assertExists(t, second)
75+}
76+
77+func TestCleanupSessionLogFiles_UsesFilenameTiebreakerForEqualModTimes(t *testing.T) {
78+ t.Parallel()
79+
80+ dir := t.TempDir()
81+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
82+
83+ oldestByName := writeSessionLog(t, dir, "20260424_120000_0001", base)
84+ keptByName := writeSessionLog(t, dir, "20260424_120000_0002", base)
85+ newestByName := writeSessionLog(t, dir, "20260424_120000_0003", base)
86+
87+ removed, err := CleanupSessionLogFiles(dir, 2)
88+ if err != nil {
89+ t.Fatalf("cleanup session log files: %v", err)
90+ }
91+ if removed != 1 {
92+ t.Fatalf("removed = %d, want 1", removed)
93+ }
94+
95+ assertMissing(t, oldestByName)
96+ assertExists(t, keptByName)
97+ assertExists(t, newestByName)
98+}
99+
100+func TestCleanupSessionLogFiles_KeepsSpecifiedSession(t *testing.T) {
101+ t.Parallel()
102+
103+ dir := t.TempDir()
104+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
105+ keepID := "20260424_120000_0001"
106+
107+ oldestKept := writeSessionLog(t, dir, keepID, base)
108+ middle := writeSessionLog(t, dir, "20260424_120001_0002", base.Add(time.Second))
109+ newest := writeSessionLog(t, dir, "20260424_120002_0003", base.Add(2*time.Second))
110+
111+ removed, err := CleanupSessionLogFiles(dir, 1, keepID)
112+ if err != nil {
113+ t.Fatalf("cleanup session log files: %v", err)
114+ }
115+ if removed != 1 {
116+ t.Fatalf("removed = %d, want 1", removed)
117+ }
118+
119+ assertExists(t, oldestKept)
120+ assertMissing(t, middle)
121+ assertExists(t, newest)
122+}
123+
124+func TestCleanupSessionLogFiles_MissingDirectoryIsNoop(t *testing.T) {
125+ t.Parallel()
126+
127+ removed, err := CleanupSessionLogFiles(filepath.Join(t.TempDir(), "missing"), 2)
128+ if err != nil {
129+ t.Fatalf("cleanup session log files: %v", err)
130+ }
131+ if removed != 0 {
132+ t.Fatalf("removed = %d, want 0", removed)
133+ }
134+}
135+
136+func writeSessionLog(t *testing.T, dir, sessionID string, modTime time.Time) string {
137+ t.Helper()
138+
139+ path := filepath.Join(dir, SessionFilename(sessionID))
140+ if err := os.WriteFile(path, []byte(sessionID), 0o644); err != nil {
141+ t.Fatalf("write session log %s: %v", sessionID, err)
142+ }
143+ if err := os.Chtimes(path, modTime, modTime); err != nil {
144+ t.Fatalf("set mod time for %s: %v", sessionID, err)
145+ }
146+ return path
147+}
148+
149+func assertExists(t *testing.T, path string) {
150+ t.Helper()
151+ if _, err := os.Stat(path); err != nil {
152+ t.Fatalf("expected %s to exist: %v", path, err)
153+ }
154+}
155+
156+func assertMissing(t *testing.T, path string) {
157+ t.Helper()
158+ if _, err := os.Stat(path); !os.IsNotExist(err) {
159+ t.Fatalf("expected %s to be removed, stat err = %v", path, err)
160+ }
161+}
@@ -0,0 +1,161 @@
1+package logging
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+)
9+
10+func TestCleanupSessionLogFiles_RemovesOldestSessionLogs(t *testing.T) {
11+ t.Parallel()
12+
13+ dir := t.TempDir()
14+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
15+
16+ oldest := writeSessionLog(t, dir, "20260424_120000_0001", base)
17+ secondOldest := writeSessionLog(t, dir, "20260424_120001_0002", base.Add(time.Second))
18+ secondNewest := writeSessionLog(t, dir, "20260424_120002_0003", base.Add(2*time.Second))
19+ newest := writeSessionLog(t, dir, "20260424_120003_0004", base.Add(3*time.Second))
20+ nonSession := filepath.Join(dir, "dumber.log")
21+ if err := os.WriteFile(nonSession, []byte("keep me"), 0o644); err != nil {
22+ t.Fatalf("write non-session log: %v", err)
23+ }
24+
25+ removed, err := CleanupSessionLogFiles(dir, 2)
26+ if err != nil {
27+ t.Fatalf("cleanup session log files: %v", err)
28+ }
29+ if removed != 2 {
30+ t.Fatalf("removed = %d, want 2", removed)
31+ }
32+
33+ assertMissing(t, oldest)
34+ assertMissing(t, secondOldest)
35+ assertExists(t, secondNewest)
36+ assertExists(t, newest)
37+ assertExists(t, nonSession)
38+}
39+
40+func TestCleanupSessionLogFiles_DisabledWhenMaxFilesZero(t *testing.T) {
41+ t.Parallel()
42+
43+ dir := t.TempDir()
44+ path := writeSessionLog(t, dir, "20260424_120000_0001", time.Now())
45+
46+ removed, err := CleanupSessionLogFiles(dir, 0)
47+ if err != nil {
48+ t.Fatalf("cleanup session log files: %v", err)
49+ }
50+ if removed != 0 {
51+ t.Fatalf("removed = %d, want 0", removed)
52+ }
53+ assertExists(t, path)
54+}
55+
56+func TestCleanupSessionLogFiles_NoRemovalWhenUnderLimit(t *testing.T) {
57+ t.Parallel()
58+
59+ dir := t.TempDir()
60+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
61+
62+ first := writeSessionLog(t, dir, "20260424_120000_0001", base)
63+ second := writeSessionLog(t, dir, "20260424_120001_0002", base.Add(time.Second))
64+
65+ removed, err := CleanupSessionLogFiles(dir, 3)
66+ if err != nil {
67+ t.Fatalf("cleanup session log files: %v", err)
68+ }
69+ if removed != 0 {
70+ t.Fatalf("removed = %d, want 0", removed)
71+ }
72+
73+ assertExists(t, first)
74+ assertExists(t, second)
75+}
76+
77+func TestCleanupSessionLogFiles_UsesFilenameTiebreakerForEqualModTimes(t *testing.T) {
78+ t.Parallel()
79+
80+ dir := t.TempDir()
81+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
82+
83+ oldestByName := writeSessionLog(t, dir, "20260424_120000_0001", base)
84+ keptByName := writeSessionLog(t, dir, "20260424_120000_0002", base)
85+ newestByName := writeSessionLog(t, dir, "20260424_120000_0003", base)
86+
87+ removed, err := CleanupSessionLogFiles(dir, 2)
88+ if err != nil {
89+ t.Fatalf("cleanup session log files: %v", err)
90+ }
91+ if removed != 1 {
92+ t.Fatalf("removed = %d, want 1", removed)
93+ }
94+
95+ assertMissing(t, oldestByName)
96+ assertExists(t, keptByName)
97+ assertExists(t, newestByName)
98+}
99+
100+func TestCleanupSessionLogFiles_KeepsSpecifiedSession(t *testing.T) {
101+ t.Parallel()
102+
103+ dir := t.TempDir()
104+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
105+ keepID := "20260424_120000_0001"
106+
107+ oldestKept := writeSessionLog(t, dir, keepID, base)
108+ middle := writeSessionLog(t, dir, "20260424_120001_0002", base.Add(time.Second))
109+ newest := writeSessionLog(t, dir, "20260424_120002_0003", base.Add(2*time.Second))
110+
111+ removed, err := CleanupSessionLogFiles(dir, 1, keepID)
112+ if err != nil {
113+ t.Fatalf("cleanup session log files: %v", err)
114+ }
115+ if removed != 1 {
116+ t.Fatalf("removed = %d, want 1", removed)
117+ }
118+
119+ assertExists(t, oldestKept)
120+ assertMissing(t, middle)
121+ assertExists(t, newest)
122+}
123+
124+func TestCleanupSessionLogFiles_MissingDirectoryIsNoop(t *testing.T) {
125+ t.Parallel()
126+
127+ removed, err := CleanupSessionLogFiles(filepath.Join(t.TempDir(), "missing"), 2)
128+ if err != nil {
129+ t.Fatalf("cleanup session log files: %v", err)
130+ }
131+ if removed != 0 {
132+ t.Fatalf("removed = %d, want 0", removed)
133+ }
134+}
135+
136+func writeSessionLog(t *testing.T, dir, sessionID string, modTime time.Time) string {
137+ t.Helper()
138+
139+ path := filepath.Join(dir, SessionFilename(sessionID))
140+ if err := os.WriteFile(path, []byte(sessionID), 0o644); err != nil {
141+ t.Fatalf("write session log %s: %v", sessionID, err)
142+ }
143+ if err := os.Chtimes(path, modTime, modTime); err != nil {
144+ t.Fatalf("set mod time for %s: %v", sessionID, err)
145+ }
146+ return path
147+}
148+
149+func assertExists(t *testing.T, path string) {
150+ t.Helper()
151+ if _, err := os.Stat(path); err != nil {
152+ t.Fatalf("expected %s to exist: %v", path, err)
153+ }
154+}
155+
156+func assertMissing(t *testing.T, path string) {
157+ t.Helper()
158+ if _, err := os.Stat(path); !os.IsNotExist(err) {
159+ t.Fatalf("expected %s to be removed, stat err = %v", path, err)
160+ }
161+}

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