ScuttleBot

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

Keyboard Shortcuts

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