Add age-based session pruning
- commit
- eadc321d6c6e2caf54c1df9ba74921f19e0c543f
- author
- patriceckhart · · 2026-08-22 15:23 UTC
- parent
- 56b82c0
- signature
- unsigned
4 files · +343 -52
M README.md +8 -6
| @@ -430,17 +430,19 @@ This is a guardrail against accidents, not a hard security boundary. If you need | |||
| 430 | 430 | ||
| 431 | 431 | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | |
| 432 | 432 | ||
| 433 | - | Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails for any reason other than "not found", which avoids deleting sessions for inaccessible or temporarily unavailable mounts. It also rechecks each selected directory immediately before deletion. | |
| 433 | + | Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups. | |
| 434 | 434 | ||
| 435 | - | For inspection or scripts, use these non-interactive options: | |
| 435 | + | Use `--older-than` to prune sessions by last activity instead, including sessions for directories that still exist. Supported age units are `m` (minutes), `h` (hours), `d` (24-hour days), `w` (7-day weeks), `mo` (calendar months), and `y` (calendar years). Add `--cwd PATH` to limit age pruning to one working directory. The session file is checked again immediately before deletion and preserved if it has become active recently. | |
| 436 | 436 | ||
| 437 | 437 | ```sh | |
| 438 | - | zot sessions prune --dry-run # list stale groups without deleting | |
| 439 | - | zot sessions prune --all # select every stale group, then confirm | |
| 440 | - | zot sessions prune --all --yes # delete every stale group without prompting | |
| 438 | + | zot sessions prune --dry-run # list missing-directory groups | |
| 439 | + | zot sessions prune --older-than 4h --dry-run # inspect sessions inactive for four hours | |
| 440 | + | zot sessions prune --older-than 1mo --cwd ~/project # select old sessions for one directory | |
| 441 | + | zot sessions prune --older-than 1y --all # select every match, then confirm | |
| 442 | + | zot sessions prune --older-than 30d --all --yes # delete every match without prompting | |
| 441 | 443 | ``` | |
| 442 | 444 | ||
| 443 | - | Malformed, unreadable, symlinked, and non-absolute session entries are reported and preserved. The scan includes normal sessions and named-agent sessions below `$ZOT_HOME/sessions/`; explicit session files stored elsewhere and swarm-agent state are not included. | |
| 445 | + | `--all` selects every match and still asks for confirmation. Adding `--yes` makes deletion non-interactive and requires `--all`. Malformed, unreadable, symlinked, and non-absolute session entries are reported and preserved during missing-directory pruning. The scan includes normal sessions and named-agent sessions below `$ZOT_HOME/sessions/`; explicit session files stored elsewhere and swarm-agent state are not included. | |
| 444 | 446 | ||
| 445 | 447 | ## Providers | |
| 446 | 448 | ||
| @@ -430,17 +430,19 @@ This is a guardrail against accidents, not a hard security boundary. If you need | |||||
| 430 | 430 | ||||
| 431 | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | 431 | Every interactive or print/json run (unless `--no-session`) writes a JSONL transcript under `$ZOT_HOME/sessions/<cwd-hash>/`. Resume any of them with `--continue`, `--resume`, `--session <path>`, or interactively via `/sessions` inside the TUI. Empty sessions (the user exited without prompting) are deleted on close so the list stays tidy. | ||
| 432 | 432 | ||||
| 433 | - | Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails for any reason other than "not found", which avoids deleting sessions for inaccessible or temporarily unavailable mounts. It also rechecks each selected directory immediately before deletion. | 433 | + | Use `zot sessions prune` outside the TUI to find sessions whose recorded working directories no longer exist. The command groups sessions by directory, shows the number of stored sessions, lets you select groups, and requires confirmation before permanently deleting files. It preserves sessions when a directory check fails with an error other than "not found" and rechecks each selected directory immediately before deletion. A deleted directory and a path hidden by some unmounted filesystems are indistinguishable, so review the selection and ensure remote filesystems are mounted before deleting missing-directory groups. |
| 434 | 434 | ||||
| 435 | - | For inspection or scripts, use these non-interactive options: | 435 | + | Use `--older-than` to prune sessions by last activity instead, including sessions for directories that still exist. Supported age units are `m` (minutes), `h` (hours), `d` (24-hour days), `w` (7-day weeks), `mo` (calendar months), and `y` (calendar years). Add `--cwd PATH` to limit age pruning to one working directory. The session file is checked again immediately before deletion and preserved if it has become active recently. |
| 436 | 436 | ||||
| 437 | ```sh | 437 | ```sh | ||
| 438 | - | zot sessions prune --dry-run # list stale groups without deleting | 438 | + | zot sessions prune --dry-run # list missing-directory groups |
| 439 | - | zot sessions prune --all # select every stale group, then confirm | 439 | + | zot sessions prune --older-than 4h --dry-run # inspect sessions inactive for four hours |
| 440 | - | zot sessions prune --all --yes # delete every stale group without prompting | 440 | + | zot sessions prune --older-than 1mo --cwd ~/project # select old sessions for one directory |
| 441 | + | zot sessions prune --older-than 1y --all # select every match, then confirm | |||
| 442 | + | zot sessions prune --older-than 30d --all --yes # delete every match without prompting | |||
| 441 | ``` | 443 | ``` | ||
| 442 | 444 | ||||
| 443 | - | Malformed, unreadable, symlinked, and non-absolute session entries are reported and preserved. The scan includes normal sessions and named-agent sessions below `$ZOT_HOME/sessions/`; explicit session files stored elsewhere and swarm-agent state are not included. | 445 | + | `--all` selects every match and still asks for confirmation. Adding `--yes` makes deletion non-interactive and requires `--all`. Malformed, unreadable, symlinked, and non-absolute session entries are reported and preserved during missing-directory pruning. The scan includes normal sessions and named-agent sessions below `$ZOT_HOME/sessions/`; explicit session files stored elsewhere and swarm-agent state are not included. |
| 444 | 446 | ||||
| 445 | ## Providers | 447 | ## Providers | ||
| 446 | 448 | ||||
M packages/agent/args.go +3 -2
| @@ -418,8 +418,9 @@ func printHelp(out *os.File, version string) { | |||
| 418 | 418 | row{"zot update --check", "show whether a new release is available"}, | |
| 419 | 419 | ) | |
| 420 | 420 | section("session management", | |
| 421 | - | row{"zot sessions prune", "select and delete sessions for directories that no longer exist"}, | |
| 422 | - | row{"zot sessions prune --dry-run", "list stale session groups without deleting them"}, | |
| 421 | + | row{"zot sessions prune", "select sessions for directories that no longer exist"}, | |
| 422 | + | row{"zot sessions prune --older-than 30d", "select sessions by time since last activity"}, | |
| 423 | + | row{"zot sessions prune --dry-run", "list matching session groups without deleting them"}, | |
| 423 | 424 | ) | |
| 424 | 425 | section("telegram", | |
| 425 | 426 | row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"}, | |
| @@ -418,8 +418,9 @@ func printHelp(out *os.File, version string) { | |||||
| 418 | row{"zot update --check", "show whether a new release is available"}, | 418 | row{"zot update --check", "show whether a new release is available"}, | ||
| 419 | ) | 419 | ) | ||
| 420 | section("session management", | 420 | section("session management", | ||
| 421 | - | row{"zot sessions prune", "select and delete sessions for directories that no longer exist"}, | 421 | + | row{"zot sessions prune", "select sessions for directories that no longer exist"}, |
| 422 | - | row{"zot sessions prune --dry-run", "list stale session groups without deleting them"}, | 422 | + | row{"zot sessions prune --older-than 30d", "select sessions by time since last activity"}, |
| 423 | + | row{"zot sessions prune --dry-run", "list matching session groups without deleting them"}, | |||
| 423 | ) | 424 | ) | ||
| 424 | section("telegram", | 425 | section("telegram", | ||
| 425 | row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"}, | 426 | row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"}, | ||
M packages/agent/sessionscmd.go +197 -41
| @@ -11,14 +11,22 @@ import ( | |||
| 11 | 11 | "sort" | |
| 12 | 12 | "strconv" | |
| 13 | 13 | "strings" | |
| 14 | + | "time" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/patriceckhart/zot/packages/core" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | type sessionsPruneOptions struct { | |
| 19 | - | dryRun bool | |
| 20 | - | all bool | |
| 21 | - | yes bool | |
| 20 | + | dryRun bool | |
| 21 | + | all bool | |
| 22 | + | yes bool | |
| 23 | + | olderThan *sessionAge | |
| 24 | + | cwd string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type sessionAge struct { | |
| 28 | + | value int | |
| 29 | + | unit string | |
| 22 | 30 | } | |
| 23 | 31 | ||
| 24 | 32 | type statPathFunc func(string) (fs.FileInfo, error) | |
| @@ -56,7 +64,8 @@ func runSessionsCommand(rawArgs []string) (handled bool, err error) { | |||
| 56 | 64 | ||
| 57 | 65 | func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | |
| 58 | 66 | var opts sessionsPruneOptions | |
| 59 | - | for _, arg := range args { | |
| 67 | + | for idx := 0; idx < len(args); idx++ { | |
| 68 | + | arg := args[idx] | |
| 60 | 69 | switch arg { | |
| 61 | 70 | case "--dry-run": | |
| 62 | 71 | opts.dryRun = true | |
| @@ -64,6 +73,25 @@ func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | |||
| 64 | 73 | opts.all = true | |
| 65 | 74 | case "-y", "--yes": | |
| 66 | 75 | opts.yes = true | |
| 76 | + | case "--older-than": | |
| 77 | + | if idx+1 >= len(args) { | |
| 78 | + | return opts, fmt.Errorf("--older-than requires an age") | |
| 79 | + | } | |
| 80 | + | idx++ | |
| 81 | + | age, err := parseSessionAge(args[idx]) | |
| 82 | + | if err != nil { | |
| 83 | + | return opts, err | |
| 84 | + | } | |
| 85 | + | opts.olderThan = &age | |
| 86 | + | case "--cwd": | |
| 87 | + | if idx+1 >= len(args) { | |
| 88 | + | return opts, fmt.Errorf("--cwd requires a path") | |
| 89 | + | } | |
| 90 | + | idx++ | |
| 91 | + | if strings.TrimSpace(args[idx]) == "" { | |
| 92 | + | return opts, fmt.Errorf("--cwd requires a non-empty path") | |
| 93 | + | } | |
| 94 | + | opts.cwd = args[idx] | |
| 67 | 95 | case "-h", "--help": | |
| 68 | 96 | return opts, errSessionsPruneHelp | |
| 69 | 97 | default: | |
| @@ -76,22 +104,92 @@ func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | |||
| 76 | 104 | if opts.yes && opts.dryRun { | |
| 77 | 105 | return opts, fmt.Errorf("--yes cannot be used with --dry-run") | |
| 78 | 106 | } | |
| 107 | + | if opts.cwd != "" && opts.olderThan == nil { | |
| 108 | + | return opts, fmt.Errorf("--cwd requires --older-than") | |
| 109 | + | } | |
| 79 | 110 | return opts, nil | |
| 80 | 111 | } | |
| 81 | 112 | ||
| 113 | + | func parseSessionAge(value string) (sessionAge, error) { | |
| 114 | + | value = strings.TrimSpace(value) | |
| 115 | + | digitEnd := 0 | |
| 116 | + | for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' { | |
| 117 | + | digitEnd++ | |
| 118 | + | } | |
| 119 | + | if digitEnd == 0 || digitEnd == len(value) { | |
| 120 | + | return sessionAge{}, fmt.Errorf("invalid age %q (use m, h, d, w, mo, or y)", value) | |
| 121 | + | } | |
| 122 | + | amount, err := strconv.ParseInt(value[:digitEnd], 10, 32) | |
| 123 | + | if err != nil || amount <= 0 { | |
| 124 | + | return sessionAge{}, fmt.Errorf("invalid age %q (value must be a positive whole number)", value) | |
| 125 | + | } | |
| 126 | + | unit := value[digitEnd:] | |
| 127 | + | switch unit { | |
| 128 | + | case "m", "h", "d", "w", "mo", "y": | |
| 129 | + | default: | |
| 130 | + | return sessionAge{}, fmt.Errorf("invalid age unit %q (use m, h, d, w, mo, or y)", unit) | |
| 131 | + | } | |
| 132 | + | age := sessionAge{value: int(amount), unit: unit} | |
| 133 | + | if _, err := age.cutoff(time.Unix(0, 0)); err != nil { | |
| 134 | + | return sessionAge{}, fmt.Errorf("invalid age %q: %w", value, err) | |
| 135 | + | } | |
| 136 | + | return age, nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (age sessionAge) cutoff(now time.Time) (time.Time, error) { | |
| 140 | + | var unit time.Duration | |
| 141 | + | switch age.unit { | |
| 142 | + | case "m": | |
| 143 | + | unit = time.Minute | |
| 144 | + | case "h": | |
| 145 | + | unit = time.Hour | |
| 146 | + | case "d": | |
| 147 | + | unit = 24 * time.Hour | |
| 148 | + | case "w": | |
| 149 | + | unit = 7 * 24 * time.Hour | |
| 150 | + | case "mo": | |
| 151 | + | return subtractCalendarAge(now, 0, age.value), nil | |
| 152 | + | case "y": | |
| 153 | + | return subtractCalendarAge(now, age.value, 0), nil | |
| 154 | + | default: | |
| 155 | + | return time.Time{}, fmt.Errorf("unsupported unit %q", age.unit) | |
| 156 | + | } | |
| 157 | + | if int64(age.value) > int64((time.Duration(1<<63-1))/unit) { | |
| 158 | + | return time.Time{}, fmt.Errorf("duration is too large") | |
| 159 | + | } | |
| 160 | + | return now.Add(-time.Duration(age.value) * unit), nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // subtractCalendarAge clamps the day to the target month's final day. This | |
| 164 | + | // makes one month before March 31 the end of February rather than early March. | |
| 165 | + | func subtractCalendarAge(now time.Time, years, months int) time.Time { | |
| 166 | + | first := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location()) | |
| 167 | + | target := first.AddDate(-years, -months, 0) | |
| 168 | + | lastDay := target.AddDate(0, 1, -1).Day() | |
| 169 | + | day := now.Day() | |
| 170 | + | if day > lastDay { | |
| 171 | + | day = lastDay | |
| 172 | + | } | |
| 173 | + | return time.Date(target.Year(), target.Month(), day, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location()) | |
| 174 | + | } | |
| 175 | + | ||
| 82 | 176 | var errSessionsPruneHelp = errors.New("sessions prune help requested") | |
| 83 | 177 | ||
| 84 | 178 | func printSessionsHelp(out io.Writer) { | |
| 85 | 179 | fmt.Fprintln(out, `zot sessions - manage stored sessions | |
| 86 | 180 | ||
| 87 | 181 | usage: | |
| 88 | - | zot sessions prune select stale directory groups and confirm deletion | |
| 89 | - | zot sessions prune --dry-run list stale groups without deleting anything | |
| 90 | - | zot sessions prune --all select every stale group, then confirm | |
| 91 | - | zot sessions prune --all --yes delete every stale group without prompting | |
| 182 | + | zot sessions prune select missing directory groups | |
| 183 | + | zot sessions prune --older-than 30d select sessions inactive for 30 days | |
| 184 | + | zot sessions prune --older-than 1mo --cwd PATH | |
| 185 | + | zot sessions prune --dry-run list matches without deleting | |
| 186 | + | zot sessions prune --all [--yes] select every match, optionally without prompting | |
| 92 | 187 | ||
| 93 | - | A group is stale only when its recorded working directory no longer exists. | |
| 94 | - | Unreadable, malformed, and temporarily inaccessible entries are preserved.`) | |
| 188 | + | Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks), | |
| 189 | + | mo (calendar months), and y (calendar years). Age uses last session activity. | |
| 190 | + | Without --older-than, only sessions whose recorded directory is missing match. | |
| 191 | + | A missing directory cannot be distinguished from some unmounted filesystems. | |
| 192 | + | Unreadable and malformed entries are preserved.`) | |
| 95 | 193 | } | |
| 96 | 194 | ||
| 97 | 195 | func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error { | |
| @@ -100,40 +198,85 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||
| 100 | 198 | fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err) | |
| 101 | 199 | } | |
| 102 | 200 | ||
| 103 | - | stale := make([]core.StoredSessionGroup, 0, len(groups)) | |
| 104 | - | for _, group := range groups { | |
| 105 | - | if !filepath.IsAbs(group.CWD) { | |
| 106 | - | fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD) | |
| 107 | - | continue | |
| 201 | + | candidates := make([]core.StoredSessionGroup, 0, len(groups)) | |
| 202 | + | var cutoff time.Time | |
| 203 | + | if opts.olderThan != nil { | |
| 204 | + | var err error | |
| 205 | + | cutoff, err = opts.olderThan.cutoff(time.Now()) | |
| 206 | + | if err != nil { | |
| 207 | + | return err | |
| 108 | 208 | } | |
| 109 | - | _, err := stat(group.CWD) | |
| 110 | - | switch { | |
| 111 | - | case err == nil: | |
| 112 | - | continue | |
| 113 | - | case errors.Is(err, fs.ErrNotExist): | |
| 114 | - | stale = append(stale, group) | |
| 115 | - | default: | |
| 116 | - | fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err) | |
| 209 | + | cwd := opts.cwd | |
| 210 | + | if cwd != "" { | |
| 211 | + | cwd, err = filepath.Abs(cwd) | |
| 212 | + | if err != nil { | |
| 213 | + | return fmt.Errorf("resolve --cwd: %w", err) | |
| 214 | + | } | |
| 215 | + | cwd = filepath.Clean(cwd) | |
| 216 | + | } | |
| 217 | + | for _, group := range groups { | |
| 218 | + | if cwd != "" && filepath.Clean(group.CWD) != cwd { | |
| 219 | + | continue | |
| 220 | + | } | |
| 221 | + | matching := core.StoredSessionGroup{CWD: group.CWD} | |
| 222 | + | for _, path := range group.Paths { | |
| 223 | + | info, err := stat(path) | |
| 224 | + | if err != nil { | |
| 225 | + | fmt.Fprintf(errOut, "warning: preserving %s: %v\n", path, err) | |
| 226 | + | continue | |
| 227 | + | } | |
| 228 | + | if info.ModTime().Before(cutoff) { | |
| 229 | + | matching.Paths = append(matching.Paths, path) | |
| 230 | + | matching.SizeBytes += info.Size() | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | if len(matching.Paths) > 0 { | |
| 234 | + | candidates = append(candidates, matching) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } else { | |
| 238 | + | for _, group := range groups { | |
| 239 | + | if !filepath.IsAbs(group.CWD) { | |
| 240 | + | fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD) | |
| 241 | + | continue | |
| 242 | + | } | |
| 243 | + | _, err := stat(group.CWD) | |
| 244 | + | switch { | |
| 245 | + | case err == nil: | |
| 246 | + | continue | |
| 247 | + | case errors.Is(err, fs.ErrNotExist): | |
| 248 | + | candidates = append(candidates, group) | |
| 249 | + | default: | |
| 250 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err) | |
| 251 | + | } | |
| 117 | 252 | } | |
| 118 | 253 | } | |
| 119 | - | if len(stale) == 0 { | |
| 120 | - | fmt.Fprintln(out, "no stale session directories found") | |
| 254 | + | if len(candidates) == 0 { | |
| 255 | + | if opts.olderThan != nil { | |
| 256 | + | fmt.Fprintln(out, "no sessions older than the requested age found") | |
| 257 | + | } else { | |
| 258 | + | fmt.Fprintln(out, "no stale session directories found") | |
| 259 | + | } | |
| 121 | 260 | return nil | |
| 122 | 261 | } | |
| 123 | 262 | ||
| 124 | - | fmt.Fprintln(out, "stale session directories:") | |
| 125 | - | for idx, group := range stale { | |
| 263 | + | if opts.olderThan != nil { | |
| 264 | + | fmt.Fprintln(out, "session directories with inactive sessions:") | |
| 265 | + | } else { | |
| 266 | + | fmt.Fprintln(out, "missing session directories:") | |
| 267 | + | } | |
| 268 | + | for idx, group := range candidates { | |
| 126 | 269 | fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes) | |
| 127 | 270 | } | |
| 128 | 271 | if opts.dryRun { | |
| 129 | - | fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(stale)), directoryCount(len(stale))) | |
| 272 | + | fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates))) | |
| 130 | 273 | return nil | |
| 131 | 274 | } | |
| 132 | 275 | ||
| 133 | 276 | reader := bufio.NewReader(in) | |
| 134 | - | selected := make([]int, 0, len(stale)) | |
| 277 | + | selected := make([]int, 0, len(candidates)) | |
| 135 | 278 | if opts.all { | |
| 136 | - | for idx := range stale { | |
| 279 | + | for idx := range candidates { | |
| 137 | 280 | selected = append(selected, idx) | |
| 138 | 281 | } | |
| 139 | 282 | } else { | |
| @@ -142,7 +285,7 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||
| 142 | 285 | if err != nil && !errors.Is(err, io.EOF) { | |
| 143 | 286 | return fmt.Errorf("read selection: %w", err) | |
| 144 | 287 | } | |
| 145 | - | selected, err = parseSessionSelection(line, len(stale)) | |
| 288 | + | selected, err = parseSessionSelection(line, len(candidates)) | |
| 146 | 289 | if err != nil { | |
| 147 | 290 | return err | |
| 148 | 291 | } | |
| @@ -154,7 +297,7 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||
| 154 | 297 | ||
| 155 | 298 | selectedSessions := 0 | |
| 156 | 299 | for _, idx := range selected { | |
| 157 | - | selectedSessions += len(stale[idx].Paths) | |
| 300 | + | selectedSessions += len(candidates[idx].Paths) | |
| 158 | 301 | } | |
| 159 | 302 | if !opts.yes { | |
| 160 | 303 | fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected))) | |
| @@ -173,19 +316,32 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||
| 173 | 316 | deletedGroups := 0 | |
| 174 | 317 | var deleteErrors []error | |
| 175 | 318 | for _, idx := range selected { | |
| 176 | - | group := stale[idx] | |
| 177 | - | _, err := stat(group.CWD) | |
| 178 | - | if err == nil { | |
| 179 | - | fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD) | |
| 180 | - | continue | |
| 181 | - | } | |
| 182 | - | if !errors.Is(err, fs.ErrNotExist) { | |
| 183 | - | fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err) | |
| 184 | - | continue | |
| 319 | + | group := candidates[idx] | |
| 320 | + | if opts.olderThan == nil { | |
| 321 | + | _, err := stat(group.CWD) | |
| 322 | + | if err == nil { | |
| 323 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD) | |
| 324 | + | continue | |
| 325 | + | } | |
| 326 | + | if !errors.Is(err, fs.ErrNotExist) { | |
| 327 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err) | |
| 328 | + | continue | |
| 329 | + | } | |
| 185 | 330 | } | |
| 186 | 331 | ||
| 187 | 332 | groupDeleted := 0 | |
| 188 | 333 | for _, path := range group.Paths { | |
| 334 | + | if opts.olderThan != nil { | |
| 335 | + | info, err := stat(path) | |
| 336 | + | if err != nil { | |
| 337 | + | fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err) | |
| 338 | + | continue | |
| 339 | + | } | |
| 340 | + | if !info.ModTime().Before(cutoff) { | |
| 341 | + | fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path) | |
| 342 | + | continue | |
| 343 | + | } | |
| 344 | + | } | |
| 189 | 345 | if err := core.DeleteSession(path); err != nil { | |
| 190 | 346 | deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err)) | |
| 191 | 347 | continue | |
| @@ -11,14 +11,22 @@ import ( | |||||
| 11 | "sort" | 11 | "sort" | ||
| 12 | "strconv" | 12 | "strconv" | ||
| 13 | "strings" | 13 | "strings" | ||
| 14 | + | "time" | |||
| 14 | 15 | ||||
| 15 | "github.com/patriceckhart/zot/packages/core" | 16 | "github.com/patriceckhart/zot/packages/core" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | type sessionsPruneOptions struct { | 19 | type sessionsPruneOptions struct { | ||
| 19 | - | dryRun bool | 20 | + | dryRun bool |
| 20 | - | all bool | 21 | + | all bool |
| 21 | - | yes bool | 22 | + | yes bool |
| 23 | + | olderThan *sessionAge | |||
| 24 | + | cwd string | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type sessionAge struct { | |||
| 28 | + | value int | |||
| 29 | + | unit string | |||
| 22 | } | 30 | } | ||
| 23 | 31 | ||||
| 24 | type statPathFunc func(string) (fs.FileInfo, error) | 32 | type statPathFunc func(string) (fs.FileInfo, error) | ||
| @@ -56,7 +64,8 @@ func runSessionsCommand(rawArgs []string) (handled bool, err error) { | |||||
| 56 | 64 | ||||
| 57 | func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | 65 | func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | ||
| 58 | var opts sessionsPruneOptions | 66 | var opts sessionsPruneOptions | ||
| 59 | - | for _, arg := range args { | 67 | + | for idx := 0; idx < len(args); idx++ { |
| 68 | + | arg := args[idx] | |||
| 60 | switch arg { | 69 | switch arg { | ||
| 61 | case "--dry-run": | 70 | case "--dry-run": | ||
| 62 | opts.dryRun = true | 71 | opts.dryRun = true | ||
| @@ -64,6 +73,25 @@ func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | |||||
| 64 | opts.all = true | 73 | opts.all = true | ||
| 65 | case "-y", "--yes": | 74 | case "-y", "--yes": | ||
| 66 | opts.yes = true | 75 | opts.yes = true | ||
| 76 | + | case "--older-than": | |||
| 77 | + | if idx+1 >= len(args) { | |||
| 78 | + | return opts, fmt.Errorf("--older-than requires an age") | |||
| 79 | + | } | |||
| 80 | + | idx++ | |||
| 81 | + | age, err := parseSessionAge(args[idx]) | |||
| 82 | + | if err != nil { | |||
| 83 | + | return opts, err | |||
| 84 | + | } | |||
| 85 | + | opts.olderThan = &age | |||
| 86 | + | case "--cwd": | |||
| 87 | + | if idx+1 >= len(args) { | |||
| 88 | + | return opts, fmt.Errorf("--cwd requires a path") | |||
| 89 | + | } | |||
| 90 | + | idx++ | |||
| 91 | + | if strings.TrimSpace(args[idx]) == "" { | |||
| 92 | + | return opts, fmt.Errorf("--cwd requires a non-empty path") | |||
| 93 | + | } | |||
| 94 | + | opts.cwd = args[idx] | |||
| 67 | case "-h", "--help": | 95 | case "-h", "--help": | ||
| 68 | return opts, errSessionsPruneHelp | 96 | return opts, errSessionsPruneHelp | ||
| 69 | default: | 97 | default: | ||
| @@ -76,22 +104,92 @@ func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) { | |||||
| 76 | if opts.yes && opts.dryRun { | 104 | if opts.yes && opts.dryRun { | ||
| 77 | return opts, fmt.Errorf("--yes cannot be used with --dry-run") | 105 | return opts, fmt.Errorf("--yes cannot be used with --dry-run") | ||
| 78 | } | 106 | } | ||
| 107 | + | if opts.cwd != "" && opts.olderThan == nil { | |||
| 108 | + | return opts, fmt.Errorf("--cwd requires --older-than") | |||
| 109 | + | } | |||
| 79 | return opts, nil | 110 | return opts, nil | ||
| 80 | } | 111 | } | ||
| 81 | 112 | ||||
| 113 | + | func parseSessionAge(value string) (sessionAge, error) { | |||
| 114 | + | value = strings.TrimSpace(value) | |||
| 115 | + | digitEnd := 0 | |||
| 116 | + | for digitEnd < len(value) && value[digitEnd] >= '0' && value[digitEnd] <= '9' { | |||
| 117 | + | digitEnd++ | |||
| 118 | + | } | |||
| 119 | + | if digitEnd == 0 || digitEnd == len(value) { | |||
| 120 | + | return sessionAge{}, fmt.Errorf("invalid age %q (use m, h, d, w, mo, or y)", value) | |||
| 121 | + | } | |||
| 122 | + | amount, err := strconv.ParseInt(value[:digitEnd], 10, 32) | |||
| 123 | + | if err != nil || amount <= 0 { | |||
| 124 | + | return sessionAge{}, fmt.Errorf("invalid age %q (value must be a positive whole number)", value) | |||
| 125 | + | } | |||
| 126 | + | unit := value[digitEnd:] | |||
| 127 | + | switch unit { | |||
| 128 | + | case "m", "h", "d", "w", "mo", "y": | |||
| 129 | + | default: | |||
| 130 | + | return sessionAge{}, fmt.Errorf("invalid age unit %q (use m, h, d, w, mo, or y)", unit) | |||
| 131 | + | } | |||
| 132 | + | age := sessionAge{value: int(amount), unit: unit} | |||
| 133 | + | if _, err := age.cutoff(time.Unix(0, 0)); err != nil { | |||
| 134 | + | return sessionAge{}, fmt.Errorf("invalid age %q: %w", value, err) | |||
| 135 | + | } | |||
| 136 | + | return age, nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (age sessionAge) cutoff(now time.Time) (time.Time, error) { | |||
| 140 | + | var unit time.Duration | |||
| 141 | + | switch age.unit { | |||
| 142 | + | case "m": | |||
| 143 | + | unit = time.Minute | |||
| 144 | + | case "h": | |||
| 145 | + | unit = time.Hour | |||
| 146 | + | case "d": | |||
| 147 | + | unit = 24 * time.Hour | |||
| 148 | + | case "w": | |||
| 149 | + | unit = 7 * 24 * time.Hour | |||
| 150 | + | case "mo": | |||
| 151 | + | return subtractCalendarAge(now, 0, age.value), nil | |||
| 152 | + | case "y": | |||
| 153 | + | return subtractCalendarAge(now, age.value, 0), nil | |||
| 154 | + | default: | |||
| 155 | + | return time.Time{}, fmt.Errorf("unsupported unit %q", age.unit) | |||
| 156 | + | } | |||
| 157 | + | if int64(age.value) > int64((time.Duration(1<<63-1))/unit) { | |||
| 158 | + | return time.Time{}, fmt.Errorf("duration is too large") | |||
| 159 | + | } | |||
| 160 | + | return now.Add(-time.Duration(age.value) * unit), nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // subtractCalendarAge clamps the day to the target month's final day. This | |||
| 164 | + | // makes one month before March 31 the end of February rather than early March. | |||
| 165 | + | func subtractCalendarAge(now time.Time, years, months int) time.Time { | |||
| 166 | + | first := time.Date(now.Year(), now.Month(), 1, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location()) | |||
| 167 | + | target := first.AddDate(-years, -months, 0) | |||
| 168 | + | lastDay := target.AddDate(0, 1, -1).Day() | |||
| 169 | + | day := now.Day() | |||
| 170 | + | if day > lastDay { | |||
| 171 | + | day = lastDay | |||
| 172 | + | } | |||
| 173 | + | return time.Date(target.Year(), target.Month(), day, now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), now.Location()) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 82 | var errSessionsPruneHelp = errors.New("sessions prune help requested") | 176 | var errSessionsPruneHelp = errors.New("sessions prune help requested") | ||
| 83 | 177 | ||||
| 84 | func printSessionsHelp(out io.Writer) { | 178 | func printSessionsHelp(out io.Writer) { | ||
| 85 | fmt.Fprintln(out, `zot sessions - manage stored sessions | 179 | fmt.Fprintln(out, `zot sessions - manage stored sessions | ||
| 86 | 180 | ||||
| 87 | usage: | 181 | usage: | ||
| 88 | - | zot sessions prune select stale directory groups and confirm deletion | 182 | + | zot sessions prune select missing directory groups |
| 89 | - | zot sessions prune --dry-run list stale groups without deleting anything | 183 | + | zot sessions prune --older-than 30d select sessions inactive for 30 days |
| 90 | - | zot sessions prune --all select every stale group, then confirm | 184 | + | zot sessions prune --older-than 1mo --cwd PATH |
| 91 | - | zot sessions prune --all --yes delete every stale group without prompting | 185 | + | zot sessions prune --dry-run list matches without deleting |
| 186 | + | zot sessions prune --all [--yes] select every match, optionally without prompting | |||
| 92 | 187 | ||||
| 93 | - | A group is stale only when its recorded working directory no longer exists. | 188 | + | Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks), |
| 94 | - | Unreadable, malformed, and temporarily inaccessible entries are preserved.`) | 189 | + | mo (calendar months), and y (calendar years). Age uses last session activity. |
| 190 | + | Without --older-than, only sessions whose recorded directory is missing match. | |||
| 191 | + | A missing directory cannot be distinguished from some unmounted filesystems. | |||
| 192 | + | Unreadable and malformed entries are preserved.`) | |||
| 95 | } | 193 | } | ||
| 96 | 194 | ||||
| 97 | func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error { | 195 | func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error { | ||
| @@ -100,40 +198,85 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||||
| 100 | fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err) | 198 | fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err) | ||
| 101 | } | 199 | } | ||
| 102 | 200 | ||||
| 103 | - | stale := make([]core.StoredSessionGroup, 0, len(groups)) | 201 | + | candidates := make([]core.StoredSessionGroup, 0, len(groups)) |
| 104 | - | for _, group := range groups { | 202 | + | var cutoff time.Time |
| 105 | - | if !filepath.IsAbs(group.CWD) { | 203 | + | if opts.olderThan != nil { |
| 106 | - | fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD) | 204 | + | var err error |
| 107 | - | continue | 205 | + | cutoff, err = opts.olderThan.cutoff(time.Now()) |
| 206 | + | if err != nil { | |||
| 207 | + | return err | |||
| 108 | } | 208 | } | ||
| 109 | - | _, err := stat(group.CWD) | 209 | + | cwd := opts.cwd |
| 110 | - | switch { | 210 | + | if cwd != "" { |
| 111 | - | case err == nil: | 211 | + | cwd, err = filepath.Abs(cwd) |
| 112 | - | continue | 212 | + | if err != nil { |
| 113 | - | case errors.Is(err, fs.ErrNotExist): | 213 | + | return fmt.Errorf("resolve --cwd: %w", err) |
| 114 | - | stale = append(stale, group) | 214 | + | } |
| 115 | - | default: | 215 | + | cwd = filepath.Clean(cwd) |
| 116 | - | fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err) | 216 | + | } |
| 217 | + | for _, group := range groups { | |||
| 218 | + | if cwd != "" && filepath.Clean(group.CWD) != cwd { | |||
| 219 | + | continue | |||
| 220 | + | } | |||
| 221 | + | matching := core.StoredSessionGroup{CWD: group.CWD} | |||
| 222 | + | for _, path := range group.Paths { | |||
| 223 | + | info, err := stat(path) | |||
| 224 | + | if err != nil { | |||
| 225 | + | fmt.Fprintf(errOut, "warning: preserving %s: %v\n", path, err) | |||
| 226 | + | continue | |||
| 227 | + | } | |||
| 228 | + | if info.ModTime().Before(cutoff) { | |||
| 229 | + | matching.Paths = append(matching.Paths, path) | |||
| 230 | + | matching.SizeBytes += info.Size() | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | if len(matching.Paths) > 0 { | |||
| 234 | + | candidates = append(candidates, matching) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | } else { | |||
| 238 | + | for _, group := range groups { | |||
| 239 | + | if !filepath.IsAbs(group.CWD) { | |||
| 240 | + | fmt.Fprintf(errOut, "warning: preserving sessions with non-absolute cwd %q\n", group.CWD) | |||
| 241 | + | continue | |||
| 242 | + | } | |||
| 243 | + | _, err := stat(group.CWD) | |||
| 244 | + | switch { | |||
| 245 | + | case err == nil: | |||
| 246 | + | continue | |||
| 247 | + | case errors.Is(err, fs.ErrNotExist): | |||
| 248 | + | candidates = append(candidates, group) | |||
| 249 | + | default: | |||
| 250 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s: %v\n", group.CWD, err) | |||
| 251 | + | } | |||
| 117 | } | 252 | } | ||
| 118 | } | 253 | } | ||
| 119 | - | if len(stale) == 0 { | 254 | + | if len(candidates) == 0 { |
| 120 | - | fmt.Fprintln(out, "no stale session directories found") | 255 | + | if opts.olderThan != nil { |
| 256 | + | fmt.Fprintln(out, "no sessions older than the requested age found") | |||
| 257 | + | } else { | |||
| 258 | + | fmt.Fprintln(out, "no stale session directories found") | |||
| 259 | + | } | |||
| 121 | return nil | 260 | return nil | ||
| 122 | } | 261 | } | ||
| 123 | 262 | ||||
| 124 | - | fmt.Fprintln(out, "stale session directories:") | 263 | + | if opts.olderThan != nil { |
| 125 | - | for idx, group := range stale { | 264 | + | fmt.Fprintln(out, "session directories with inactive sessions:") |
| 265 | + | } else { | |||
| 266 | + | fmt.Fprintln(out, "missing session directories:") | |||
| 267 | + | } | |||
| 268 | + | for idx, group := range candidates { | |||
| 126 | fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes) | 269 | fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes) | ||
| 127 | } | 270 | } | ||
| 128 | if opts.dryRun { | 271 | if opts.dryRun { | ||
| 129 | - | fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(stale)), directoryCount(len(stale))) | 272 | + | fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates))) |
| 130 | return nil | 273 | return nil | ||
| 131 | } | 274 | } | ||
| 132 | 275 | ||||
| 133 | reader := bufio.NewReader(in) | 276 | reader := bufio.NewReader(in) | ||
| 134 | - | selected := make([]int, 0, len(stale)) | 277 | + | selected := make([]int, 0, len(candidates)) |
| 135 | if opts.all { | 278 | if opts.all { | ||
| 136 | - | for idx := range stale { | 279 | + | for idx := range candidates { |
| 137 | selected = append(selected, idx) | 280 | selected = append(selected, idx) | ||
| 138 | } | 281 | } | ||
| 139 | } else { | 282 | } else { | ||
| @@ -142,7 +285,7 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||||
| 142 | if err != nil && !errors.Is(err, io.EOF) { | 285 | if err != nil && !errors.Is(err, io.EOF) { | ||
| 143 | return fmt.Errorf("read selection: %w", err) | 286 | return fmt.Errorf("read selection: %w", err) | ||
| 144 | } | 287 | } | ||
| 145 | - | selected, err = parseSessionSelection(line, len(stale)) | 288 | + | selected, err = parseSessionSelection(line, len(candidates)) |
| 146 | if err != nil { | 289 | if err != nil { | ||
| 147 | return err | 290 | return err | ||
| 148 | } | 291 | } | ||
| @@ -154,7 +297,7 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||||
| 154 | 297 | ||||
| 155 | selectedSessions := 0 | 298 | selectedSessions := 0 | ||
| 156 | for _, idx := range selected { | 299 | for _, idx := range selected { | ||
| 157 | - | selectedSessions += len(stale[idx].Paths) | 300 | + | selectedSessions += len(candidates[idx].Paths) |
| 158 | } | 301 | } | ||
| 159 | if !opts.yes { | 302 | if !opts.yes { | ||
| 160 | fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected))) | 303 | fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected))) | ||
| @@ -173,19 +316,32 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr | |||||
| 173 | deletedGroups := 0 | 316 | deletedGroups := 0 | ||
| 174 | var deleteErrors []error | 317 | var deleteErrors []error | ||
| 175 | for _, idx := range selected { | 318 | for _, idx := range selected { | ||
| 176 | - | group := stale[idx] | 319 | + | group := candidates[idx] |
| 177 | - | _, err := stat(group.CWD) | 320 | + | if opts.olderThan == nil { |
| 178 | - | if err == nil { | 321 | + | _, err := stat(group.CWD) |
| 179 | - | fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD) | 322 | + | if err == nil { |
| 180 | - | continue | 323 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s because the directory now exists\n", group.CWD) |
| 181 | - | } | 324 | + | continue |
| 182 | - | if !errors.Is(err, fs.ErrNotExist) { | 325 | + | } |
| 183 | - | fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err) | 326 | + | if !errors.Is(err, fs.ErrNotExist) { |
| 184 | - | continue | 327 | + | fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err) |
| 328 | + | continue | |||
| 329 | + | } | |||
| 185 | } | 330 | } | ||
| 186 | 331 | ||||
| 187 | groupDeleted := 0 | 332 | groupDeleted := 0 | ||
| 188 | for _, path := range group.Paths { | 333 | for _, path := range group.Paths { | ||
| 334 | + | if opts.olderThan != nil { | |||
| 335 | + | info, err := stat(path) | |||
| 336 | + | if err != nil { | |||
| 337 | + | fmt.Fprintf(errOut, "warning: preserving %s after recheck: %v\n", path, err) | |||
| 338 | + | continue | |||
| 339 | + | } | |||
| 340 | + | if !info.ModTime().Before(cutoff) { | |||
| 341 | + | fmt.Fprintf(errOut, "warning: preserving %s because it now has recent activity\n", path) | |||
| 342 | + | continue | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 189 | if err := core.DeleteSession(path); err != nil { | 345 | if err := core.DeleteSession(path); err != nil { | ||
| 190 | deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err)) | 346 | deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err)) | ||
| 191 | continue | 347 | continue | ||
M packages/agent/sessionscmd_test.go +135 -3
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "reflect" | |
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/patriceckhart/zot/packages/core" | |
| 13 | 14 | "github.com/patriceckhart/zot/packages/provider" | |
| @@ -42,6 +43,45 @@ func TestParseSessionSelection(t *testing.T) { | |||
| 42 | 43 | } | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 46 | + | func TestParseSessionAge(t *testing.T) { | |
| 47 | + | now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC) | |
| 48 | + | tests := []struct { | |
| 49 | + | input string | |
| 50 | + | want time.Time | |
| 51 | + | wantErr bool | |
| 52 | + | }{ | |
| 53 | + | {input: "30m", want: now.Add(-30 * time.Minute)}, | |
| 54 | + | {input: "4h", want: now.Add(-4 * time.Hour)}, | |
| 55 | + | {input: "30d", want: now.Add(-30 * 24 * time.Hour)}, | |
| 56 | + | {input: "2w", want: now.Add(-14 * 24 * time.Hour)}, | |
| 57 | + | {input: "1mo", want: now.AddDate(0, -1, 0)}, | |
| 58 | + | {input: "1y", want: now.AddDate(-1, 0, 0)}, | |
| 59 | + | {input: "", wantErr: true}, | |
| 60 | + | {input: "0d", wantErr: true}, | |
| 61 | + | {input: "-1d", wantErr: true}, | |
| 62 | + | {input: "1.5h", wantErr: true}, | |
| 63 | + | {input: "1s", wantErr: true}, | |
| 64 | + | } | |
| 65 | + | for _, tt := range tests { | |
| 66 | + | t.Run(tt.input, func(t *testing.T) { | |
| 67 | + | age, err := parseSessionAge(tt.input) | |
| 68 | + | if (err != nil) != tt.wantErr { | |
| 69 | + | t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) | |
| 70 | + | } | |
| 71 | + | if tt.wantErr { | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | got, err := age.cutoff(now) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | if !got.Equal(tt.want) { | |
| 79 | + | t.Fatalf("cutoff = %v, want %v", got, tt.want) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 45 | 85 | func TestSessionsPruneDryRunPreservesSessions(t *testing.T) { | |
| 46 | 86 | home := t.TempDir() | |
| 47 | 87 | t.Setenv("ZOT_HOME", home) | |
| @@ -112,6 +152,95 @@ func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing. | |||
| 112 | 152 | } | |
| 113 | 153 | } | |
| 114 | 154 | ||
| 155 | + | func TestSessionAgeClampsCalendarBoundaries(t *testing.T) { | |
| 156 | + | month, err := parseSessionAge("1mo") | |
| 157 | + | if err != nil { | |
| 158 | + | t.Fatal(err) | |
| 159 | + | } | |
| 160 | + | got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC)) | |
| 161 | + | if err != nil { | |
| 162 | + | t.Fatal(err) | |
| 163 | + | } | |
| 164 | + | want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC) | |
| 165 | + | if !got.Equal(want) { | |
| 166 | + | t.Fatalf("one month cutoff = %v, want %v", got, want) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | year, err := parseSessionAge("1y") | |
| 170 | + | if err != nil { | |
| 171 | + | t.Fatal(err) | |
| 172 | + | } | |
| 173 | + | got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC)) | |
| 174 | + | if err != nil { | |
| 175 | + | t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC) | |
| 178 | + | if !got.Equal(want) { | |
| 179 | + | t.Fatalf("one year cutoff = %v, want %v", got, want) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) { | |
| 184 | + | home := t.TempDir() | |
| 185 | + | t.Setenv("ZOT_HOME", home) | |
| 186 | + | cwd := t.TempDir() | |
| 187 | + | oldPath := createPruneTestSession(t, home, cwd) | |
| 188 | + | newPath := createPruneTestSession(t, home, cwd) | |
| 189 | + | oldTime := time.Now().Add(-48 * time.Hour) | |
| 190 | + | if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | age, err := parseSessionAge("24h") | |
| 195 | + | if err != nil { | |
| 196 | + | t.Fatal(err) | |
| 197 | + | } | |
| 198 | + | var out, errOut bytes.Buffer | |
| 199 | + | opts := sessionsPruneOptions{dryRun: true, olderThan: &age} | |
| 200 | + | if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |
| 201 | + | t.Fatal(err) | |
| 202 | + | } | |
| 203 | + | if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") { | |
| 204 | + | t.Fatalf("output = %q", out.String()) | |
| 205 | + | } | |
| 206 | + | for _, path := range []string{oldPath, newPath} { | |
| 207 | + | if _, err := os.Stat(path); err != nil { | |
| 208 | + | t.Fatalf("dry run removed %s: %v", path, err) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) { | |
| 214 | + | home := t.TempDir() | |
| 215 | + | t.Setenv("ZOT_HOME", home) | |
| 216 | + | firstCWD := t.TempDir() | |
| 217 | + | secondCWD := t.TempDir() | |
| 218 | + | first := createPruneTestSession(t, home, firstCWD) | |
| 219 | + | second := createPruneTestSession(t, home, secondCWD) | |
| 220 | + | oldTime := time.Now().Add(-48 * time.Hour) | |
| 221 | + | for _, path := range []string{first, second} { | |
| 222 | + | if err := os.Chtimes(path, oldTime, oldTime); err != nil { | |
| 223 | + | t.Fatal(err) | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | age, err := parseSessionAge("24h") | |
| 228 | + | if err != nil { | |
| 229 | + | t.Fatal(err) | |
| 230 | + | } | |
| 231 | + | var out, errOut bytes.Buffer | |
| 232 | + | opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD} | |
| 233 | + | if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |
| 234 | + | t.Fatal(err) | |
| 235 | + | } | |
| 236 | + | if _, err := os.Stat(first); !os.IsNotExist(err) { | |
| 237 | + | t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err) | |
| 238 | + | } | |
| 239 | + | if _, err := os.Stat(second); err != nil { | |
| 240 | + | t.Fatalf("session from another cwd was removed: %v", err) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 115 | 244 | func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) { | |
| 116 | 245 | home := t.TempDir() | |
| 117 | 246 | t.Setenv("ZOT_HOME", home) | |
| @@ -139,15 +268,18 @@ func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) { | |||
| 139 | 268 | } | |
| 140 | 269 | } | |
| 141 | 270 | ||
| 142 | - | func TestParseSessionsPruneOptionsRequiresAllForYes(t *testing.T) { | |
| 271 | + | func TestParseSessionsPruneOptions(t *testing.T) { | |
| 143 | 272 | if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil { | |
| 144 | 273 | t.Fatal("--yes without --all was accepted") | |
| 145 | 274 | } | |
| 146 | - | opts, err := parseSessionsPruneOptions([]string{"--all", "--yes"}) | |
| 275 | + | if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil { | |
| 276 | + | t.Fatal("--cwd without --older-than was accepted") | |
| 277 | + | } | |
| 278 | + | opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"}) | |
| 147 | 279 | if err != nil { | |
| 148 | 280 | t.Fatal(err) | |
| 149 | 281 | } | |
| 150 | - | if !opts.all || !opts.yes { | |
| 282 | + | if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" { | |
| 151 | 283 | t.Fatalf("options = %#v", opts) | |
| 152 | 284 | } | |
| 153 | 285 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "reflect" | 8 | "reflect" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "testing" | 10 | "testing" | ||
| 11 | + | "time" | |||
| 11 | 12 | ||||
| 12 | "github.com/patriceckhart/zot/packages/core" | 13 | "github.com/patriceckhart/zot/packages/core" | ||
| 13 | "github.com/patriceckhart/zot/packages/provider" | 14 | "github.com/patriceckhart/zot/packages/provider" | ||
| @@ -42,6 +43,45 @@ func TestParseSessionSelection(t *testing.T) { | |||||
| 42 | } | 43 | } | ||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 46 | + | func TestParseSessionAge(t *testing.T) { | |||
| 47 | + | now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC) | |||
| 48 | + | tests := []struct { | |||
| 49 | + | input string | |||
| 50 | + | want time.Time | |||
| 51 | + | wantErr bool | |||
| 52 | + | }{ | |||
| 53 | + | {input: "30m", want: now.Add(-30 * time.Minute)}, | |||
| 54 | + | {input: "4h", want: now.Add(-4 * time.Hour)}, | |||
| 55 | + | {input: "30d", want: now.Add(-30 * 24 * time.Hour)}, | |||
| 56 | + | {input: "2w", want: now.Add(-14 * 24 * time.Hour)}, | |||
| 57 | + | {input: "1mo", want: now.AddDate(0, -1, 0)}, | |||
| 58 | + | {input: "1y", want: now.AddDate(-1, 0, 0)}, | |||
| 59 | + | {input: "", wantErr: true}, | |||
| 60 | + | {input: "0d", wantErr: true}, | |||
| 61 | + | {input: "-1d", wantErr: true}, | |||
| 62 | + | {input: "1.5h", wantErr: true}, | |||
| 63 | + | {input: "1s", wantErr: true}, | |||
| 64 | + | } | |||
| 65 | + | for _, tt := range tests { | |||
| 66 | + | t.Run(tt.input, func(t *testing.T) { | |||
| 67 | + | age, err := parseSessionAge(tt.input) | |||
| 68 | + | if (err != nil) != tt.wantErr { | |||
| 69 | + | t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) | |||
| 70 | + | } | |||
| 71 | + | if tt.wantErr { | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | got, err := age.cutoff(now) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | if !got.Equal(tt.want) { | |||
| 79 | + | t.Fatalf("cutoff = %v, want %v", got, tt.want) | |||
| 80 | + | } | |||
| 81 | + | }) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 45 | func TestSessionsPruneDryRunPreservesSessions(t *testing.T) { | 85 | func TestSessionsPruneDryRunPreservesSessions(t *testing.T) { | ||
| 46 | home := t.TempDir() | 86 | home := t.TempDir() | ||
| 47 | t.Setenv("ZOT_HOME", home) | 87 | t.Setenv("ZOT_HOME", home) | ||
| @@ -112,6 +152,95 @@ func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing. | |||||
| 112 | } | 152 | } | ||
| 113 | } | 153 | } | ||
| 114 | 154 | ||||
| 155 | + | func TestSessionAgeClampsCalendarBoundaries(t *testing.T) { | |||
| 156 | + | month, err := parseSessionAge("1mo") | |||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatal(err) | |||
| 159 | + | } | |||
| 160 | + | got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC)) | |||
| 161 | + | if err != nil { | |||
| 162 | + | t.Fatal(err) | |||
| 163 | + | } | |||
| 164 | + | want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC) | |||
| 165 | + | if !got.Equal(want) { | |||
| 166 | + | t.Fatalf("one month cutoff = %v, want %v", got, want) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | year, err := parseSessionAge("1y") | |||
| 170 | + | if err != nil { | |||
| 171 | + | t.Fatal(err) | |||
| 172 | + | } | |||
| 173 | + | got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC)) | |||
| 174 | + | if err != nil { | |||
| 175 | + | t.Fatal(err) | |||
| 176 | + | } | |||
| 177 | + | want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC) | |||
| 178 | + | if !got.Equal(want) { | |||
| 179 | + | t.Fatalf("one year cutoff = %v, want %v", got, want) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) { | |||
| 184 | + | home := t.TempDir() | |||
| 185 | + | t.Setenv("ZOT_HOME", home) | |||
| 186 | + | cwd := t.TempDir() | |||
| 187 | + | oldPath := createPruneTestSession(t, home, cwd) | |||
| 188 | + | newPath := createPruneTestSession(t, home, cwd) | |||
| 189 | + | oldTime := time.Now().Add(-48 * time.Hour) | |||
| 190 | + | if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { | |||
| 191 | + | t.Fatal(err) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | age, err := parseSessionAge("24h") | |||
| 195 | + | if err != nil { | |||
| 196 | + | t.Fatal(err) | |||
| 197 | + | } | |||
| 198 | + | var out, errOut bytes.Buffer | |||
| 199 | + | opts := sessionsPruneOptions{dryRun: true, olderThan: &age} | |||
| 200 | + | if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |||
| 201 | + | t.Fatal(err) | |||
| 202 | + | } | |||
| 203 | + | if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") { | |||
| 204 | + | t.Fatalf("output = %q", out.String()) | |||
| 205 | + | } | |||
| 206 | + | for _, path := range []string{oldPath, newPath} { | |||
| 207 | + | if _, err := os.Stat(path); err != nil { | |||
| 208 | + | t.Fatalf("dry run removed %s: %v", path, err) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) { | |||
| 214 | + | home := t.TempDir() | |||
| 215 | + | t.Setenv("ZOT_HOME", home) | |||
| 216 | + | firstCWD := t.TempDir() | |||
| 217 | + | secondCWD := t.TempDir() | |||
| 218 | + | first := createPruneTestSession(t, home, firstCWD) | |||
| 219 | + | second := createPruneTestSession(t, home, secondCWD) | |||
| 220 | + | oldTime := time.Now().Add(-48 * time.Hour) | |||
| 221 | + | for _, path := range []string{first, second} { | |||
| 222 | + | if err := os.Chtimes(path, oldTime, oldTime); err != nil { | |||
| 223 | + | t.Fatal(err) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | age, err := parseSessionAge("24h") | |||
| 228 | + | if err != nil { | |||
| 229 | + | t.Fatal(err) | |||
| 230 | + | } | |||
| 231 | + | var out, errOut bytes.Buffer | |||
| 232 | + | opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD} | |||
| 233 | + | if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil { | |||
| 234 | + | t.Fatal(err) | |||
| 235 | + | } | |||
| 236 | + | if _, err := os.Stat(first); !os.IsNotExist(err) { | |||
| 237 | + | t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err) | |||
| 238 | + | } | |||
| 239 | + | if _, err := os.Stat(second); err != nil { | |||
| 240 | + | t.Fatalf("session from another cwd was removed: %v", err) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 115 | func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) { | 244 | func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) { | ||
| 116 | home := t.TempDir() | 245 | home := t.TempDir() | ||
| 117 | t.Setenv("ZOT_HOME", home) | 246 | t.Setenv("ZOT_HOME", home) | ||
| @@ -139,15 +268,18 @@ func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) { | |||||
| 139 | } | 268 | } | ||
| 140 | } | 269 | } | ||
| 141 | 270 | ||||
| 142 | - | func TestParseSessionsPruneOptionsRequiresAllForYes(t *testing.T) { | 271 | + | func TestParseSessionsPruneOptions(t *testing.T) { |
| 143 | if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil { | 272 | if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil { | ||
| 144 | t.Fatal("--yes without --all was accepted") | 273 | t.Fatal("--yes without --all was accepted") | ||
| 145 | } | 274 | } | ||
| 146 | - | opts, err := parseSessionsPruneOptions([]string{"--all", "--yes"}) | 275 | + | if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil { |
| 276 | + | t.Fatal("--cwd without --older-than was accepted") | |||
| 277 | + | } | |||
| 278 | + | opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"}) | |||
| 147 | if err != nil { | 279 | if err != nil { | ||
| 148 | t.Fatal(err) | 280 | t.Fatal(err) | ||
| 149 | } | 281 | } | ||
| 150 | - | if !opts.all || !opts.yes { | 282 | + | if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" { |
| 151 | t.Fatalf("options = %#v", opts) | 283 | t.Fatalf("options = %#v", opts) | ||
| 152 | } | 284 | } | ||
| 153 | } | 285 | } | ||