ScuttleBot

scuttlebot / internal / config / history.go
Blame History Raw 121 lines
1
package config
2
3
import (
4
"fmt"
5
"io"
6
"os"
7
"path/filepath"
8
"sort"
9
"strings"
10
"time"
11
)
12
13
// HistoryEntry describes a single config snapshot in the history directory.
14
type HistoryEntry struct {
15
// Filename is the base name of the snapshot file (e.g. "scuttlebot.yaml.20260402-143022").
16
Filename string `json:"filename"`
17
18
// Timestamp is when the snapshot was taken, parsed from the filename.
19
Timestamp time.Time `json:"timestamp"`
20
21
// Size is the file size in bytes.
22
Size int64 `json:"size"`
23
}
24
25
const historyTimestampFormat = "20060102-150405"
26
27
// SnapshotConfig copies the file at configPath into historyDir, naming it
28
// "<basename>.<timestamp>". It creates historyDir if it does not exist.
29
// It is a no-op if configPath does not exist yet.
30
func SnapshotConfig(historyDir, configPath string) error {
31
src, err := os.Open(configPath)
32
if os.IsNotExist(err) {
33
return nil // nothing to snapshot
34
}
35
if err != nil {
36
return fmt.Errorf("config history: open %s: %w", configPath, err)
37
}
38
defer src.Close()
39
40
if err := os.MkdirAll(historyDir, 0o700); err != nil {
41
return fmt.Errorf("config history: mkdir %s: %w", historyDir, err)
42
}
43
44
base := filepath.Base(configPath)
45
stamp := time.Now().Format(historyTimestampFormat)
46
dst := filepath.Join(historyDir, base+"."+stamp)
47
48
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
49
if err != nil {
50
return fmt.Errorf("config history: create snapshot %s: %w", dst, err)
51
}
52
defer out.Close()
53
54
if _, err := io.Copy(out, src); err != nil {
55
return fmt.Errorf("config history: write snapshot %s: %w", dst, err)
56
}
57
return nil
58
}
59
60
// PruneHistory removes the oldest snapshots in historyDir until at most keep
61
// files remain. It only considers files whose names start with base (the
62
// basename of the config file). keep ≤ 0 means unlimited (no pruning).
63
func PruneHistory(historyDir, base string, keep int) error {
64
if keep <= 0 {
65
return nil
66
}
67
entries, err := listHistory(historyDir, base)
68
if err != nil {
69
return err
70
}
71
for len(entries) > keep {
72
oldest := entries[0]
73
if err := os.Remove(filepath.Join(historyDir, oldest.Filename)); err != nil && !os.IsNotExist(err) {
74
return fmt.Errorf("config history: remove %s: %w", oldest.Filename, err)
75
}
76
entries = entries[1:]
77
}
78
return nil
79
}
80
81
// ListHistory returns all snapshots for base (the config file basename) in
82
// historyDir, sorted oldest-first.
83
func ListHistory(historyDir, base string) ([]HistoryEntry, error) {
84
return listHistory(historyDir, base)
85
}
86
87
func listHistory(historyDir, base string) ([]HistoryEntry, error) {
88
des, err := os.ReadDir(historyDir)
89
if os.IsNotExist(err) {
90
return nil, nil
91
}
92
if err != nil {
93
return nil, fmt.Errorf("config history: readdir %s: %w", historyDir, err)
94
}
95
var out []HistoryEntry
96
prefix := base + "."
97
for _, de := range des {
98
if de.IsDir() || !strings.HasPrefix(de.Name(), prefix) {
99
continue
100
}
101
stamp := strings.TrimPrefix(de.Name(), prefix)
102
t, err := time.ParseInLocation(historyTimestampFormat, stamp, time.Local)
103
if err != nil {
104
continue // skip files with non-matching suffix
105
}
106
info, err := de.Info()
107
if err != nil {
108
continue
109
}
110
out = append(out, HistoryEntry{
111
Filename: de.Name(),
112
Timestamp: t,
113
Size: info.Size(),
114
})
115
}
116
sort.Slice(out, func(i, j int) bool {
117
return out[i].Timestamp.Before(out[j].Timestamp)
118
})
119
return out, nil
120
}
121

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button