detached 06c3b66

Merge remote-tracking branch 'origin/main' into feat/systemviews-wasm

commit
06c3b66990b4feb854ec678a54ff10d5b8c3501c
author
brice · · 2026-04-25 08:03 UTC
merge of
1bac075 + 35d15a2 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +351 -18
M docs/config/index.md +6 -5
@@ -123,13 +123,14 @@ auto_open_on_new_pane = false
123123
124124 | Key | Type | Default | Valid Values | Description |
125125 |-----|------|---------|--------------|-------------|
126-| `logging.level` | string | `"info"` | `debug`, `info`, `warn`, `error` | Log level |
127-| `logging.format` | string | `"text"` | `text`, `json` | Log format |
128-| `logging.max_age` | int | `7` | >= 0 | Days to keep logs |
129-| `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory |
126+| `logging.level` | string | `"info"` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` | Log level |
127+| `logging.format` | string | `"text"` | `text`, `json`, `console` | Log format |
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) |
130+| `logging.log_dir` | string | `~/.local/state/dumber/logs` | any path | Log directory |
130131 | `logging.enable_file_log` | bool | `true` | - | Enable file logging |
131132 | `logging.capture_console` | bool | `false` | - | Capture browser console to logs |
132-| `logging.capture_gtk_webkit_logs` | bool | `false` | - | Capture GTK and WebKit logs for debugging |
133+| `logging.capture_gtk_logs` | bool | `false` | - | Capture GTK and WebKit logs for debugging. |
133134
134135 ## Appearance
135136
@@ -123,13 +123,14 @@ auto_open_on_new_pane = false
123 123
124 | Key | Type | Default | Valid Values | Description |124 | Key | Type | Default | Valid Values | Description |
125 |-----|------|---------|--------------|-------------|125 |-----|------|---------|--------------|-------------|
126-| `logging.level` | string | `"info"` | `debug`, `info`, `warn`, `error` | Log level |126+| `logging.level` | string | `"info"` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` | Log level |
127-| `logging.format` | string | `"text"` | `text`, `json` | Log format |127+| `logging.format` | string | `"text"` | `text`, `json`, `console` | 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.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory |129+| `logging.max_files` | int | `100` | `>= 0` | Session log files to keep (`0` disables count cleanup) |
130+| `logging.log_dir` | string | `~/.local/state/dumber/logs` | any path | 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 |
132-| `logging.capture_gtk_webkit_logs` | bool | `false` | - | Capture GTK and WebKit logs for debugging |133+| `logging.capture_gtk_logs` | bool | `false` | - | Capture GTK and WebKit logs for debugging. |
133 134
134 ## Appearance135 ## Appearance
135 136
M docs/reference/configuration.md +5 -4
@@ -20,13 +20,14 @@
2020 | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` |
2121 | `omnibox.most_visited_days` | int | `30` | `>= 0` |
2222 | `omnibox.auto_open_on_new_pane` | bool | `false` | |
23-| `logging.level` | string | `info` | `debug`, `info`, `warn`, `error` |
24-| `logging.format` | string | `text` | `text`, `json` |
23+| `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` |
24+| `logging.format` | string | `text` | `text`, `json`, `console` |
2525 | `logging.max_age` | int | `7` | >= 0 |
26-| `logging.log_dir` | string | `~/.local/state/dumber/logs/` | |
26+| `logging.max_files` | int | `100` | >= 0 (0 disables count cleanup) |
27+| `logging.log_dir` | string | `~/.local/state/dumber/logs` | any path |
2728 | `logging.enable_file_log` | bool | `true` | |
2829 | `logging.capture_console` | bool | `false` | |
29-| `logging.capture_gtk_webkit_logs` | bool | `false` | |
30+| `logging.capture_gtk_logs` | bool | `false` | |
3031 | `appearance.sans_font` | string | `Fira Sans` | |
3132 | `appearance.serif_font` | string | `Fira Sans` | |
3233 | `appearance.monospace_font` | string | `Fira Code` | |
@@ -20,13 +20,14 @@
20 | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` |20 | `omnibox.initial_behavior` | string | `recent` | `recent`, `most_visited`, `none` |
21 | `omnibox.most_visited_days` | int | `30` | `>= 0` |21 | `omnibox.most_visited_days` | int | `30` | `>= 0` |
22 | `omnibox.auto_open_on_new_pane` | bool | `false` | |22 | `omnibox.auto_open_on_new_pane` | bool | `false` | |
23-| `logging.level` | string | `info` | `debug`, `info`, `warn`, `error` |23+| `logging.level` | string | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` |
24-| `logging.format` | string | `text` | `text`, `json` |24+| `logging.format` | string | `text` | `text`, `json`, `console` |
25 | `logging.max_age` | int | `7` | >= 0 |25 | `logging.max_age` | int | `7` | >= 0 |
26-| `logging.log_dir` | string | `~/.local/state/dumber/logs/` | |26+| `logging.max_files` | int | `100` | >= 0 (0 disables count cleanup) |
27+| `logging.log_dir` | string | `~/.local/state/dumber/logs` | any path |
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` | |
29-| `logging.capture_gtk_webkit_logs` | bool | `false` | |30+| `logging.capture_gtk_logs` | bool | `false` | |
30 | `appearance.sans_font` | string | `Fira Sans` | |31 | `appearance.sans_font` | string | `Fira Sans` | |
31 | `appearance.serif_font` | string | `Fira Sans` | |32 | `appearance.serif_font` | string | `Fira Sans` | |
32 | `appearance.monospace_font` | string | `Fira Code` | |33 | `appearance.monospace_font` | string | `Fira Code` | |
M internal/bootstrap/session.go +20 -3
@@ -102,7 +102,7 @@ func StartBrowserSession(
102102 Level: cfg.Logging.Level,
103103 Format: cfg.Logging.Format,
104104 TimeFormat: corelogging.ConsoleTimeFormat,
105- LogDir: cfg.Logging.LogDir,
105+ LogDir: logDir,
106106 WriteToStderr: true,
107107 EnableFileLog: cfg.Logging.EnableFileLog,
108108 })
@@ -156,7 +156,7 @@ func StartBrowserSession(
156156 }
157157
158158 // Background cleanup of old sessions.
159- go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, session.ID, log)
159+ go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, logDir, session.ID, log)
160160 })
161161 return persistErr
162162 }
@@ -310,6 +310,7 @@ func runSessionCleanup(
310310 sessionUC *usecase.ManageSessionUseCase,
311311 cleanupUC *usecase.CleanupSessionsUseCase,
312312 cfg *config.Config,
313+ logDir string,
313314 currentSessionID entity.SessionID,
314315 log *zerolog.Logger,
315316 ) {
@@ -347,7 +348,23 @@ func runSessionCleanup(
347348 }
348349
349350 // Prune old marker files left by previous versions.
350- sweepLegacyMarkers(cfg.Logging.LogDir, log)
351+ sweepLegacyMarkers(logDir, log)
352+
353+ removedLogs, logCleanupErr := corelogging.CleanupSessionLogFiles(
354+ logDir,
355+ cfg.Logging.MaxFiles,
356+ string(currentSessionID),
357+ )
358+ if logCleanupErr != nil {
359+ if log != nil {
360+ log.Warn().Err(logCleanupErr).Msg("background: failed to cleanup session log files")
361+ }
362+ } else if removedLogs > 0 && log != nil {
363+ log.Info().
364+ Int("deleted", removedLogs).
365+ Int("max_files", cfg.Logging.MaxFiles).
366+ Msg("background: cleaned up session log files")
367+ }
351368 }
352369
353370 // sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
@@ -102,7 +102,7 @@ func StartBrowserSession(
102 Level: cfg.Logging.Level,102 Level: cfg.Logging.Level,
103 Format: cfg.Logging.Format,103 Format: cfg.Logging.Format,
104 TimeFormat: corelogging.ConsoleTimeFormat,104 TimeFormat: corelogging.ConsoleTimeFormat,
105- LogDir: cfg.Logging.LogDir,105+ LogDir: logDir,
106 WriteToStderr: true,106 WriteToStderr: true,
107 EnableFileLog: cfg.Logging.EnableFileLog,107 EnableFileLog: cfg.Logging.EnableFileLog,
108 })108 })
@@ -156,7 +156,7 @@ func StartBrowserSession(
156 }156 }
157 157
158 // Background cleanup of old sessions.158 // Background cleanup of old sessions.
159- go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, session.ID, log)159+ go runSessionCleanup(persistCtx, sessionUC, cleanupUC, cfg, logDir, session.ID, log)
160 })160 })
161 return persistErr161 return persistErr
162 }162 }
@@ -310,6 +310,7 @@ func runSessionCleanup(
310 sessionUC *usecase.ManageSessionUseCase,310 sessionUC *usecase.ManageSessionUseCase,
311 cleanupUC *usecase.CleanupSessionsUseCase,311 cleanupUC *usecase.CleanupSessionsUseCase,
312 cfg *config.Config,312 cfg *config.Config,
313+ logDir string,
313 currentSessionID entity.SessionID,314 currentSessionID entity.SessionID,
314 log *zerolog.Logger,315 log *zerolog.Logger,
315 ) {316 ) {
@@ -347,7 +348,23 @@ func runSessionCleanup(
347 }348 }
348 349
349 // Prune old marker files left by previous versions.350 // Prune old marker files left by previous versions.
350- sweepLegacyMarkers(cfg.Logging.LogDir, log)351+ sweepLegacyMarkers(logDir, log)
352+
353+ removedLogs, logCleanupErr := corelogging.CleanupSessionLogFiles(
354+ logDir,
355+ cfg.Logging.MaxFiles,
356+ string(currentSessionID),
357+ )
358+ if logCleanupErr != nil {
359+ if log != nil {
360+ log.Warn().Err(logCleanupErr).Msg("background: failed to cleanup session log files")
361+ }
362+ } else if removedLogs > 0 && log != nil {
363+ log.Info().
364+ Int("deleted", removedLogs).
365+ Int("max_files", cfg.Logging.MaxFiles).
366+ Msg("background: cleaned up session log files")
367+ }
351 }368 }
352 369
353 // sweepLegacyMarkers removes .startup.marker, .shutdown.marker,370 // sweepLegacyMarkers removes .startup.marker, .shutdown.marker,
M internal/bootstrap/session_test.go +4 -2
@@ -21,6 +21,7 @@ func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
2121 sessionUC := usecase.NewManageSessionUseCase(repo, nil)
2222 cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
2323 cfg := config.DefaultConfig()
24+ logDir := t.TempDir()
2425
2526 now := time.Now().UTC()
2627 current := &entity.Session{
@@ -45,7 +46,7 @@ func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
4546 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
4647 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
4748
48- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, current.ID, nil)
49+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, current.ID, nil)
4950 }
5051
5152 func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
@@ -56,12 +57,13 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
5657 sessionUC := usecase.NewManageSessionUseCase(repo, nil)
5758 cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
5859 cfg := config.DefaultConfig()
60+ logDir := t.TempDir()
5961
6062 repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{nil}, nil).Once()
6163 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
6264 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
6365
6466 require.NotPanics(t, func() {
65- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, entity.SessionID("current"), nil)
67+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, entity.SessionID("current"), nil)
6668 })
6769 }
@@ -21,6 +21,7 @@ func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
21 sessionUC := usecase.NewManageSessionUseCase(repo, nil)21 sessionUC := usecase.NewManageSessionUseCase(repo, nil)
22 cleanupUC := usecase.NewCleanupSessionsUseCase(repo)22 cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
23 cfg := config.DefaultConfig()23 cfg := config.DefaultConfig()
24+ logDir := t.TempDir()
24 25
25 now := time.Now().UTC()26 now := time.Now().UTC()
26 current := &entity.Session{27 current := &entity.Session{
@@ -45,7 +46,7 @@ func TestRunSessionCleanup_SkipsCurrentActiveSession(t *testing.T) {
45 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()46 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
46 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()47 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
47 48
48- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, current.ID, nil)49+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, current.ID, nil)
49 }50 }
50 51
51 func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {52 func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
@@ -56,12 +57,13 @@ func TestRunSessionCleanup_NoPanicOnNilSessions(t *testing.T) {
56 sessionUC := usecase.NewManageSessionUseCase(repo, nil)57 sessionUC := usecase.NewManageSessionUseCase(repo, nil)
57 cleanupUC := usecase.NewCleanupSessionsUseCase(repo)58 cleanupUC := usecase.NewCleanupSessionsUseCase(repo)
58 cfg := config.DefaultConfig()59 cfg := config.DefaultConfig()
60+ logDir := t.TempDir()
59 61
60 repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{nil}, nil).Once()62 repo.EXPECT().GetRecent(mock.Anything, recentSessionsLimit).Return([]*entity.Session{nil}, nil).Once()
61 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()63 repo.EXPECT().DeleteExitedBefore(mock.Anything, mock.MatchedBy(func(time.Time) bool { return true })).Return(int64(0), nil).Once()
62 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()64 repo.EXPECT().DeleteOldestExited(mock.Anything, cfg.Session.MaxExitedSessions).Return(int64(0), nil).Once()
63 65
64 require.NotPanics(t, func() {66 require.NotPanics(t, func() {
65- runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, entity.SessionID("current"), nil)67+ runSessionCleanup(ctx, sessionUC, cleanupUC, cfg, logDir, entity.SessionID("current"), nil)
66 })68 })
67 }69 }
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
@@ -10,6 +10,7 @@ import (
1010 func TestDefaultConfig_CoreDefaults(t *testing.T) {
1111 cfg := DefaultConfig()
1212 assert.Equal(t, "info", cfg.Logging.Level)
13+ assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
1314 assert.False(t, cfg.Logging.CaptureGTKLogs)
1415 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
1516 assert.False(t, cfg.Transcoding.Enabled)
@@ -10,6 +10,7 @@ import (
10 func TestDefaultConfig_CoreDefaults(t *testing.T) {10 func TestDefaultConfig_CoreDefaults(t *testing.T) {
11 cfg := DefaultConfig()11 cfg := DefaultConfig()
12 assert.Equal(t, "info", cfg.Logging.Level)12 assert.Equal(t, "info", cfg.Logging.Level)
13+ assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles)
13 assert.False(t, cfg.Logging.CaptureGTKLogs)14 assert.False(t, cfg.Logging.CaptureGTKLogs)
14 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)15 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
15 assert.False(t, cfg.Transcoding.Enabled)16 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 +109 -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,107 @@ 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 up to maxFiles of the newest requested session IDs, then the newest remaining files by modification time.
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+ requestedKeep := make(map[string]struct{}, len(keepSessionIDs))
82+ for _, sessionID := range keepSessionIDs {
83+ if sessionID == "" {
84+ continue
85+ }
86+ requestedKeep[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+ allowedKeep := boundedKeepSessionIDs(files, requestedKeep, maxFiles)
123+ maxNewestFiles := maxFiles - len(allowedKeep)
124+ kept := 0
125+ keptNewest := 0
126+ removed := 0
127+ for _, file := range files {
128+ if _, shouldKeep := allowedKeep[file.sessionID]; shouldKeep {
129+ if kept < maxFiles {
130+ kept++
131+ continue
132+ }
133+ } else if keptNewest < maxNewestFiles && kept < maxFiles {
134+ kept++
135+ keptNewest++
136+ continue
137+ }
138+
139+ if err := os.Remove(file.path); err != nil {
140+ operationErrs = append(operationErrs, fmt.Errorf("remove %s: %w", file.path, err))
141+ continue
142+ }
143+ removed++
144+ }
145+
146+ return removed, errors.Join(operationErrs...)
147+}
148+
149+func boundedKeepSessionIDs(files []sessionLogFile, requestedKeep map[string]struct{}, maxFiles int) map[string]struct{} {
150+ allowedKeep := make(map[string]struct{}, len(requestedKeep))
151+ for _, file := range files {
152+ if len(allowedKeep) >= maxFiles {
153+ break
154+ }
155+ if _, shouldKeep := requestedKeep[file.sessionID]; shouldKeep {
156+ allowedKeep[file.sessionID] = struct{}{}
157+ }
158+ }
159+ return allowedKeep
160+}
@@ -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,107 @@ 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 up to maxFiles of the newest requested session IDs, then the newest remaining files by modification time.
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+ requestedKeep := make(map[string]struct{}, len(keepSessionIDs))
82+ for _, sessionID := range keepSessionIDs {
83+ if sessionID == "" {
84+ continue
85+ }
86+ requestedKeep[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+ allowedKeep := boundedKeepSessionIDs(files, requestedKeep, maxFiles)
123+ maxNewestFiles := maxFiles - len(allowedKeep)
124+ kept := 0
125+ keptNewest := 0
126+ removed := 0
127+ for _, file := range files {
128+ if _, shouldKeep := allowedKeep[file.sessionID]; shouldKeep {
129+ if kept < maxFiles {
130+ kept++
131+ continue
132+ }
133+ } else if keptNewest < maxNewestFiles && kept < maxFiles {
134+ kept++
135+ keptNewest++
136+ continue
137+ }
138+
139+ if err := os.Remove(file.path); err != nil {
140+ operationErrs = append(operationErrs, fmt.Errorf("remove %s: %w", file.path, err))
141+ continue
142+ }
143+ removed++
144+ }
145+
146+ return removed, errors.Join(operationErrs...)
147+}
148+
149+func boundedKeepSessionIDs(files []sessionLogFile, requestedKeep map[string]struct{}, maxFiles int) map[string]struct{} {
150+ allowedKeep := make(map[string]struct{}, len(requestedKeep))
151+ for _, file := range files {
152+ if len(allowedKeep) >= maxFiles {
153+ break
154+ }
155+ if _, shouldKeep := requestedKeep[file.sessionID]; shouldKeep {
156+ allowedKeep[file.sessionID] = struct{}{}
157+ }
158+ }
159+ return allowedKeep
160+}
A internal/logging/session_test.go +187 -0
@@ -0,0 +1,187 @@
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_KeepsSpecifiedSessionWithinLimit(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 != 2 {
116+ t.Fatalf("removed = %d, want 2", removed)
117+ }
118+
119+ assertExists(t, oldestKept)
120+ assertMissing(t, middle)
121+ assertMissing(t, newest)
122+}
123+
124+func TestCleanupSessionLogFiles_BoundsSpecifiedSessionsToMaxFiles(t *testing.T) {
125+ t.Parallel()
126+
127+ dir := t.TempDir()
128+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
129+
130+ oldestKeptID := "20260424_120000_0001"
131+ middleKeptID := "20260424_120001_0002"
132+ newestKeptID := "20260424_120002_0003"
133+ oldestKept := writeSessionLog(t, dir, oldestKeptID, base)
134+ middleKept := writeSessionLog(t, dir, middleKeptID, base.Add(time.Second))
135+ newestKept := writeSessionLog(t, dir, newestKeptID, base.Add(2*time.Second))
136+
137+ removed, err := CleanupSessionLogFiles(dir, 2, oldestKeptID, middleKeptID, newestKeptID)
138+ if err != nil {
139+ t.Fatalf("cleanup session log files: %v", err)
140+ }
141+ if removed != 1 {
142+ t.Fatalf("removed = %d, want 1", removed)
143+ }
144+
145+ assertMissing(t, oldestKept)
146+ assertExists(t, middleKept)
147+ assertExists(t, newestKept)
148+}
149+
150+func TestCleanupSessionLogFiles_MissingDirectoryIsNoop(t *testing.T) {
151+ t.Parallel()
152+
153+ removed, err := CleanupSessionLogFiles(filepath.Join(t.TempDir(), "missing"), 2)
154+ if err != nil {
155+ t.Fatalf("cleanup session log files: %v", err)
156+ }
157+ if removed != 0 {
158+ t.Fatalf("removed = %d, want 0", removed)
159+ }
160+}
161+
162+func writeSessionLog(t *testing.T, dir, sessionID string, modTime time.Time) string {
163+ t.Helper()
164+
165+ path := filepath.Join(dir, SessionFilename(sessionID))
166+ if err := os.WriteFile(path, []byte(sessionID), 0o644); err != nil {
167+ t.Fatalf("write session log %s: %v", sessionID, err)
168+ }
169+ if err := os.Chtimes(path, modTime, modTime); err != nil {
170+ t.Fatalf("set mod time for %s: %v", sessionID, err)
171+ }
172+ return path
173+}
174+
175+func assertExists(t *testing.T, path string) {
176+ t.Helper()
177+ if _, err := os.Stat(path); err != nil {
178+ t.Fatalf("expected %s to exist: %v", path, err)
179+ }
180+}
181+
182+func assertMissing(t *testing.T, path string) {
183+ t.Helper()
184+ if _, err := os.Stat(path); !os.IsNotExist(err) {
185+ t.Fatalf("expected %s to be removed, stat err = %v", path, err)
186+ }
187+}
@@ -0,0 +1,187 @@
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_KeepsSpecifiedSessionWithinLimit(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 != 2 {
116+ t.Fatalf("removed = %d, want 2", removed)
117+ }
118+
119+ assertExists(t, oldestKept)
120+ assertMissing(t, middle)
121+ assertMissing(t, newest)
122+}
123+
124+func TestCleanupSessionLogFiles_BoundsSpecifiedSessionsToMaxFiles(t *testing.T) {
125+ t.Parallel()
126+
127+ dir := t.TempDir()
128+ base := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
129+
130+ oldestKeptID := "20260424_120000_0001"
131+ middleKeptID := "20260424_120001_0002"
132+ newestKeptID := "20260424_120002_0003"
133+ oldestKept := writeSessionLog(t, dir, oldestKeptID, base)
134+ middleKept := writeSessionLog(t, dir, middleKeptID, base.Add(time.Second))
135+ newestKept := writeSessionLog(t, dir, newestKeptID, base.Add(2*time.Second))
136+
137+ removed, err := CleanupSessionLogFiles(dir, 2, oldestKeptID, middleKeptID, newestKeptID)
138+ if err != nil {
139+ t.Fatalf("cleanup session log files: %v", err)
140+ }
141+ if removed != 1 {
142+ t.Fatalf("removed = %d, want 1", removed)
143+ }
144+
145+ assertMissing(t, oldestKept)
146+ assertExists(t, middleKept)
147+ assertExists(t, newestKept)
148+}
149+
150+func TestCleanupSessionLogFiles_MissingDirectoryIsNoop(t *testing.T) {
151+ t.Parallel()
152+
153+ removed, err := CleanupSessionLogFiles(filepath.Join(t.TempDir(), "missing"), 2)
154+ if err != nil {
155+ t.Fatalf("cleanup session log files: %v", err)
156+ }
157+ if removed != 0 {
158+ t.Fatalf("removed = %d, want 0", removed)
159+ }
160+}
161+
162+func writeSessionLog(t *testing.T, dir, sessionID string, modTime time.Time) string {
163+ t.Helper()
164+
165+ path := filepath.Join(dir, SessionFilename(sessionID))
166+ if err := os.WriteFile(path, []byte(sessionID), 0o644); err != nil {
167+ t.Fatalf("write session log %s: %v", sessionID, err)
168+ }
169+ if err := os.Chtimes(path, modTime, modTime); err != nil {
170+ t.Fatalf("set mod time for %s: %v", sessionID, err)
171+ }
172+ return path
173+}
174+
175+func assertExists(t *testing.T, path string) {
176+ t.Helper()
177+ if _, err := os.Stat(path); err != nil {
178+ t.Fatalf("expected %s to exist: %v", path, err)
179+ }
180+}
181+
182+func assertMissing(t *testing.T, path string) {
183+ t.Helper()
184+ if _, err := os.Stat(path); !os.IsNotExist(err) {
185+ t.Fatalf("expected %s to be removed, stat err = %v", path, err)
186+ }
187+}

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