v0.3.48 9b7bb6a

sessionscmd.go RawHistoryFolder

1package agent
2
3import (
4	"bufio"
5	"errors"
6	"fmt"
7	"io"
8	"io/fs"
9	"os"
10	"path/filepath"
11	"sort"
12	"strconv"
13	"strings"
14	"time"
15
16	"github.com/patriceckhart/zot/packages/core"
17)
18
19type sessionsPruneOptions struct {
20	dryRun    bool
21	all       bool
22	yes       bool
23	olderThan *sessionAge
24	cwd       string
25}
26
27type sessionAge struct {
28	value int
29	unit  string
30}
31
32type statPathFunc func(string) (fs.FileInfo, error)
33
34// runSessionsCommand dispatches standalone session management commands before
35// normal agent startup, so they do not require a provider or credentials.
36func runSessionsCommand(rawArgs []string) (handled bool, err error) {
37	if len(rawArgs) == 0 || rawArgs[0] != "sessions" {
38		return false, nil
39	}
40	if len(rawArgs) == 1 {
41		printSessionsHelp(os.Stdout)
42		return true, nil
43	}
44	switch rawArgs[1] {
45	case "help", "-h", "--help":
46		printSessionsHelp(os.Stdout)
47		return true, nil
48	case "prune":
49		opts, err := parseSessionsPruneOptions(rawArgs[2:])
50		if errors.Is(err, errSessionsPruneHelp) {
51			printSessionsHelp(os.Stdout)
52			return true, nil
53		}
54		if err != nil {
55			printSessionsHelp(os.Stderr)
56			return true, err
57		}
58		return true, runSessionsPrune(opts, os.Stdin, os.Stdout, os.Stderr, os.Stat)
59	default:
60		printSessionsHelp(os.Stderr)
61		return true, fmt.Errorf("unknown sessions subcommand: %s", rawArgs[1])
62	}
63}
64
65func parseSessionsPruneOptions(args []string) (sessionsPruneOptions, error) {
66	var opts sessionsPruneOptions
67	for idx := 0; idx < len(args); idx++ {
68		arg := args[idx]
69		switch arg {
70		case "--dry-run":
71			opts.dryRun = true
72		case "--all":
73			opts.all = true
74		case "-y", "--yes":
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]
95		case "-h", "--help":
96			return opts, errSessionsPruneHelp
97		default:
98			return opts, fmt.Errorf("unknown sessions prune flag: %s", arg)
99		}
100	}
101	if opts.yes && !opts.all {
102		return opts, fmt.Errorf("--yes requires --all")
103	}
104	if opts.yes && opts.dryRun {
105		return opts, fmt.Errorf("--yes cannot be used with --dry-run")
106	}
107	if opts.cwd != "" && opts.olderThan == nil {
108		return opts, fmt.Errorf("--cwd requires --older-than")
109	}
110	return opts, nil
111}
112
113func 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
139func (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.
165func 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
176var errSessionsPruneHelp = errors.New("sessions prune help requested")
177
178func printSessionsHelp(out io.Writer) {
179	fmt.Fprintln(out, `zot sessions - manage stored sessions
180
181usage:
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
187
188Age units are m (minutes), h (hours), d (24-hour days), w (7-day weeks),
189mo (calendar months), and y (calendar years). Age uses last session activity.
190Without --older-than, only sessions whose recorded directory is missing match.
191A missing directory cannot be distinguished from some unmounted filesystems.
192Unreadable and malformed entries are preserved.`)
193}
194
195func runSessionsPrune(opts sessionsPruneOptions, in io.Reader, out, errOut io.Writer, stat statPathFunc) error {
196	groups, scanIssues := core.ScanStoredSessionGroups(SessionsPath())
197	for _, issue := range scanIssues {
198		fmt.Fprintf(errOut, "warning: preserving %s: %v\n", issue.Path, issue.Err)
199	}
200
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
208		}
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			}
252		}
253	}
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		}
260		return nil
261	}
262
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 {
269		fmt.Fprintf(out, "  %d. %s (%s, %d bytes)\n", idx+1, group.CWD, sessionCount(len(group.Paths)), group.SizeBytes)
270	}
271	if opts.dryRun {
272		fmt.Fprintf(out, "dry run: %s in %s would be deleted\n", sessionCount(totalSessions(candidates)), directoryCount(len(candidates)))
273		return nil
274	}
275
276	reader := bufio.NewReader(in)
277	selected := make([]int, 0, len(candidates))
278	if opts.all {
279		for idx := range candidates {
280			selected = append(selected, idx)
281		}
282	} else {
283		fmt.Fprint(out, "select groups to delete (for example 1,3-5; all; none): ")
284		line, err := reader.ReadString('\n')
285		if err != nil && !errors.Is(err, io.EOF) {
286			return fmt.Errorf("read selection: %w", err)
287		}
288		selected, err = parseSessionSelection(line, len(candidates))
289		if err != nil {
290			return err
291		}
292		if len(selected) == 0 {
293			fmt.Fprintln(out, "no sessions deleted")
294			return nil
295		}
296	}
297
298	selectedSessions := 0
299	for _, idx := range selected {
300		selectedSessions += len(candidates[idx].Paths)
301	}
302	if !opts.yes {
303		fmt.Fprintf(out, "permanently delete %s in %s? [y/N]: ", sessionCount(selectedSessions), directoryCount(len(selected)))
304		line, err := reader.ReadString('\n')
305		if err != nil && !errors.Is(err, io.EOF) {
306			return fmt.Errorf("read confirmation: %w", err)
307		}
308		answer := strings.ToLower(strings.TrimSpace(line))
309		if answer != "y" && answer != "yes" {
310			fmt.Fprintln(out, "no sessions deleted")
311			return nil
312		}
313	}
314
315	deletedSessions := 0
316	deletedGroups := 0
317	var deleteErrors []error
318	for _, idx := range selected {
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			}
330		}
331
332		groupDeleted := 0
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			}
345			if err := core.DeleteSession(path); err != nil {
346				deleteErrors = append(deleteErrors, fmt.Errorf("delete %s: %w", path, err))
347				continue
348			}
349			groupDeleted++
350			_ = os.Remove(filepath.Dir(path))
351		}
352		deletedSessions += groupDeleted
353		if groupDeleted == len(group.Paths) {
354			deletedGroups++
355		}
356	}
357	fmt.Fprintf(out, "deleted %s from %s\n", sessionCount(deletedSessions), directoryCount(deletedGroups))
358	return errors.Join(deleteErrors...)
359}
360
361func parseSessionSelection(input string, count int) ([]int, error) {
362	input = strings.ToLower(strings.TrimSpace(input))
363	switch input {
364	case "", "none", "n":
365		return nil, nil
366	case "all", "a":
367		selected := make([]int, count)
368		for idx := range selected {
369			selected[idx] = idx
370		}
371		return selected, nil
372	}
373
374	seen := make(map[int]bool)
375	for _, field := range strings.Split(input, ",") {
376		field = strings.TrimSpace(field)
377		if field == "" {
378			return nil, fmt.Errorf("invalid empty selection")
379		}
380		startText, endText, isRange := strings.Cut(field, "-")
381		start, err := strconv.Atoi(strings.TrimSpace(startText))
382		if err != nil {
383			return nil, fmt.Errorf("invalid selection %q", field)
384		}
385		end := start
386		if isRange {
387			end, err = strconv.Atoi(strings.TrimSpace(endText))
388			if err != nil || end < start {
389				return nil, fmt.Errorf("invalid selection range %q", field)
390			}
391		}
392		if start < 1 || end > count {
393			return nil, fmt.Errorf("selection %q is outside 1-%d", field, count)
394		}
395		for value := start; value <= end; value++ {
396			seen[value-1] = true
397		}
398	}
399	selected := make([]int, 0, len(seen))
400	for idx := range seen {
401		selected = append(selected, idx)
402	}
403	sort.Ints(selected)
404	return selected, nil
405}
406
407func totalSessions(groups []core.StoredSessionGroup) int {
408	total := 0
409	for _, group := range groups {
410		total += len(group.Paths)
411	}
412	return total
413}
414
415func sessionCount(count int) string {
416	if count == 1 {
417		return "1 session"
418	}
419	return fmt.Sprintf("%d sessions", count)
420}
421
422func directoryCount(count int) string {
423	if count == 1 {
424		return "1 directory"
425	}
426	return fmt.Sprintf("%d directories", count)
427}

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