detached eadc321

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
430430
431431 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.
432432
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.
434434
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.
436436
437437 ```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
441443 ```
442444
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.
444446
445447 ## Providers
446448
@@ -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 ```sh437 ```sh
438-zot sessions prune --dry-run # list stale groups without deleting438+zot sessions prune --dry-run # list missing-directory groups
439-zot sessions prune --all # select every stale group, then confirm439+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 prompting440+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 ## Providers447 ## Providers
446 448
M packages/agent/args.go +3 -2
@@ -418,8 +418,9 @@ func printHelp(out *os.File, version string) {
418418 row{"zot update --check", "show whether a new release is available"},
419419 )
420420 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"},
423424 )
424425 section("telegram",
425426 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 (
1111 "sort"
1212 "strconv"
1313 "strings"
14+ "time"
1415
1516 "github.com/patriceckhart/zot/packages/core"
1617 )
1718
1819 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
2230 }
2331
2432 type statPathFunc func(string) (fs.FileInfo, error)
@@ -56,7 +64,8 @@ func runSessionsCommand(rawArgs []string) (handled bool, err error) {
5664
5765 func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
5866 var opts sessionsPruneOptions
59- for _, arg := range args {
67+ for idx := 0; idx < len(args); idx++ {
68+ arg := args[idx]
6069 switch arg {
6170 case "--dry-run":
6271 opts.dryRun = true
@@ -64,6 +73,25 @@ func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
6473 opts.all = true
6574 case "-y", "--yes":
6675 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]
6795 case "-h", "--help":
6896 return opts, errSessionsPruneHelp
6997 default:
@@ -76,22 +104,92 @@ func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
76104 if opts.yes && opts.dryRun {
77105 return opts, fmt.Errorf("--yes cannot be used with --dry-run")
78106 }
107+ if opts.cwd != "" && opts.olderThan == nil {
108+ return opts, fmt.Errorf("--cwd requires --older-than")
109+ }
79110 return opts, nil
80111 }
81112
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+
82176 var errSessionsPruneHelp = errors.New("sessions prune help requested")
83177
84178 func printSessionsHelp(out io.Writer) {
85179 fmt.Fprintln(out, `zot sessions - manage stored sessions
86180
87181 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
92187
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.`)
95193 }
96194
97195 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
100198 fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
101199 }
102200
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
108208 }
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+ }
117252 }
118253 }
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+ }
121260 return nil
122261 }
123262
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 {
126269 fmt.Fprintf(out, " %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
127270 }
128271 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)))
130273 return nil
131274 }
132275
133276 reader := bufio.NewReader(in)
134- selected := make([]int, 0, len(stale))
277+ selected := make([]int, 0, len(candidates))
135278 if opts.all {
136- for idx := range stale {
279+ for idx := range candidates {
137280 selected = append(selected, idx)
138281 }
139282 } else {
@@ -142,7 +285,7 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr
142285 if err != nil && !errors.Is(err, io.EOF) {
143286 return fmt.Errorf("read selection: %w", err)
144287 }
145- selected, err = parseSessionSelection(line, len(stale))
288+ selected, err = parseSessionSelection(line, len(candidates))
146289 if err != nil {
147290 return err
148291 }
@@ -154,7 +297,7 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr
154297
155298 selectedSessions := 0
156299 for _, idx := range selected {
157- selectedSessions += len(stale[idx].Paths)
300+ selectedSessions += len(candidates[idx].Paths)
158301 }
159302 if !opts.yes {
160303 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
173316 deletedGroups := 0
174317 var deleteErrors []error
175318 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+ }
185330 }
186331
187332 groupDeleted := 0
188333 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+ }
189345 if err := core.DeleteSession(path); err != nil {
190346 deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
191347 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 bool20+ dryRun bool
20- all bool21+ all bool
21- yes bool22+ 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 sessionsPruneOptions66 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 = true71 opts.dryRun = true
@@ -64,6 +73,25 @@ func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
64 opts.all = true73 opts.all = true
65 case "-y", "--yes":74 case "-y", "--yes":
66 opts.yes = true75 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, errSessionsPruneHelp96 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, nil110 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 sessions179 fmt.Fprintln(out, `zot sessions - manage stored sessions
86 180
87 usage:181 usage:
88- zot sessions prune select stale directory groups and confirm deletion182+ zot sessions prune select missing directory groups
89- zot sessions prune --dry-run list stale groups without deleting anything183+ zot sessions prune --older-than 30d select sessions inactive for 30 days
90- zot sessions prune --all select every stale group, then confirm184+ zot sessions prune --older-than 1mo --cwd PATH
91- zot sessions prune --all --yes delete every stale group without prompting185+ 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- continue205+ 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- continue212+ 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 nil260 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 nil273 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 err290 return err
148 }291 }
@@ -154,7 +297,7 @@ func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Wr
154 297
155 selectedSessions := 0298 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 := 0316 deletedGroups := 0
174 var deleteErrors []error317 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- continue323+ 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- continue327+ fmt.Fprintf(errOut, "warning: preserving sessions for %s after recheck: %v\n", group.CWD, err)
328+ continue
329+ }
185 }330 }
186 331
187 groupDeleted := 0332 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 continue347 continue
M packages/agent/sessionscmd_test.go +135 -3
@@ -8,6 +8,7 @@ import (
88 "reflect"
99 "strings"
1010 "testing"
11+ "time"
1112
1213 "github.com/patriceckhart/zot/packages/core"
1314 "github.com/patriceckhart/zot/packages/provider"
@@ -42,6 +43,45 @@ func TestParseSessionSelection(t *testing.T) {
4243 }
4344 }
4445
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+
4585 func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
4686 home := t.TempDir()
4787 t.Setenv("ZOT_HOME", home)
@@ -112,6 +152,95 @@ func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.
112152 }
113153 }
114154
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+
115244 func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
116245 home := t.TempDir()
117246 t.Setenv("ZOT_HOME", home)
@@ -139,15 +268,18 @@ func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
139268 }
140269 }
141270
142-func TestParseSessionsPruneOptionsRequiresAllForYes(t *testing.T) {
271+func TestParseSessionsPruneOptions(t *testing.T) {
143272 if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
144273 t.Fatal("--yes without --all was accepted")
145274 }
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"})
147279 if err != nil {
148280 t.Fatal(err)
149281 }
150- if !opts.all || !opts.yes {
282+ if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" {
151283 t.Fatalf("options = %#v", opts)
152284 }
153285 }
@@ -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 }

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 1

Tags 476

↑↓ moveenter openesc close